diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/Expander/ExpanderPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/Expander/ExpanderPage.xaml
index d085d031a4..891bce85cc 100644
--- a/samples/CommunityToolkit.Maui.Sample/Pages/Views/Expander/ExpanderPage.xaml
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/Expander/ExpanderPage.xaml
@@ -22,6 +22,10 @@
+
+
+
+
@@ -36,11 +40,21 @@
+
+
+
+
+
+
+
+
+
+
@@ -58,6 +72,11 @@
+
+
+
+
+
@@ -83,6 +102,7 @@
diff --git a/src/CommunityToolkit.Maui.Core/Primitives/Defaults/ExpanderAnimationBehaviorDefaults.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/ExpanderAnimationBehaviorDefaults.shared.cs
new file mode 100644
index 0000000000..4adf94fabc
--- /dev/null
+++ b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/ExpanderAnimationBehaviorDefaults.shared.cs
@@ -0,0 +1,9 @@
+namespace CommunityToolkit.Maui.Core;
+
+static class ExpanderAnimationBehaviorDefaults
+{
+ public const uint CollapsingLength = 250u;
+ public static Easing CollapsingEasing { get; } = Easing.Linear;
+ public const uint ExpandingLength = 250u;
+ public static Easing ExpandingEasing { get; } = Easing.Linear;
+}
diff --git a/src/CommunityToolkit.Maui.Core/Primitives/ExpandedChangingEventArgs.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/ExpandedChangingEventArgs.shared.cs
new file mode 100644
index 0000000000..e97f18a66a
--- /dev/null
+++ b/src/CommunityToolkit.Maui.Core/Primitives/ExpandedChangingEventArgs.shared.cs
@@ -0,0 +1,17 @@
+namespace CommunityToolkit.Maui.Core;
+
+///
+/// Provides data for an event that occurs when an Expander is about to change its IsExpanded state.
+///
+public class ExpandedChangingEventArgs(bool oldIsExpanded, bool newIsExpanded) : EventArgs
+{
+ ///
+ /// True if expander was expanded before the change.
+ ///
+ public bool OldIsExpanded { get; } = oldIsExpanded;
+
+ ///
+ /// True if expander will be expanded after the change.
+ ///
+ public bool NewIsExpanded { get; } = newIsExpanded;
+}
diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Expander/ExpanderTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Expander/ExpanderTests.cs
index 30b18fadb0..30b490c317 100644
--- a/src/CommunityToolkit.Maui.UnitTests/Views/Expander/ExpanderTests.cs
+++ b/src/CommunityToolkit.Maui.UnitTests/Views/Expander/ExpanderTests.cs
@@ -1,5 +1,8 @@
using System.ComponentModel;
+using System.Text;
+using CommunityToolkit.Maui.Behaviors;
using CommunityToolkit.Maui.Core;
+using CommunityToolkit.Maui.Views;
using FluentAssertions;
using Xunit;
@@ -117,4 +120,156 @@ public void EnsureDefaults()
var expander = new Maui.Views.Expander();
Assert.Equal(ExpanderDefaults.Direction, expander.Direction);
}
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task ExpanderRaisesEventsInCorrectOrderWhenExpanding()
+ {
+ TaskCompletionSource tcs = new();
+ var expander = new Maui.Views.Expander();
+ StringBuilder callOrder = new();
+ expander.ExpandedChanging += (_, _) => callOrder.Append("changing,");
+ expander.ExpandedChanged += (_, _) => { callOrder.Append("changed,"); tcs.TrySetResult(); };
+ var controller = new MockExpansionController();
+ controller.Expanding += (_, _) => callOrder.Append("controllerExpanding,");
+ controller.Collapsing += (_, _) => callOrder.Append("controllerCollapsing,");
+ expander.ExpansionController = controller;
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ expander.IsExpanded = true;
+ await tcs.Task;
+ Assert.Equal("changing,controllerExpanding,changed,", callOrder.ToString());
+ }
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task ExpanderCallsCorrectControllerMethod()
+ {
+ TaskCompletionSource tcs = new();
+ var expander = new Maui.Views.Expander();
+ expander.ExpandedChanged += (_, _) => tcs.TrySetResult();
+ var controller = new MockExpansionController();
+ expander.ExpansionController = controller;
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ expander.IsExpanded = true;
+ await tcs.Task;
+ Assert.Equal(1, controller.ExpandingCount);
+ Assert.Equal(0, controller.CollapsingCount);
+ }
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task ExpanderExpandsContentBecomesVisibleHeightRestored()
+ {
+ var tcs = new TaskCompletionSource();
+ var expander = new Maui.Views.Expander();
+ expander.ExpandedChanged += (_, _) => tcs.TrySetResult();
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ expander.IsExpanded = true;
+ await tcs.Task;
+ var element = expander.Content as VisualElement;
+ Assert.NotNull(expander.ContentHost);
+ Assert.NotNull(expander.Content);
+ Assert.NotNull(element);
+ Assert.True(element.IsVisible);
+ Assert.Equal(-1, expander.ContentHost.HeightRequest);
+ }
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task ExpanderCollapseContentHiddenHeightZero()
+ {
+ var expander = new Maui.Views.Expander();
+ expander.IsExpanded = true;
+ var tcs = new TaskCompletionSource();
+ expander.ExpandedChanged += (_, _) => tcs.TrySetResult();
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ expander.IsExpanded = false;
+ await tcs.Task;
+ var element = expander.Content as VisualElement;
+ Assert.NotNull(expander.ContentHost);
+ Assert.NotNull(expander.Content);
+ Assert.NotNull(element);
+ Assert.False(element.IsVisible);
+ Assert.Equal(0, expander.ContentHost.HeightRequest);
+ }
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task ExpanderIsExpandedSetBeforeContentAssignedInitialStateCorrect()
+ {
+ var expander = new Maui.Views.Expander();
+ expander.IsExpanded = true;
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ var element = expander.Content as VisualElement;
+ Assert.NotNull(expander.ContentHost);
+ Assert.NotNull(element);
+ Assert.True(element.IsVisible);
+ Assert.Equal(-1, expander.ContentHost.HeightRequest);
+ }
+
+ [Fact]
+ public void AttachingAnimationBehaviorSetsExpansionController()
+ {
+ var expander = new Maui.Views.Expander();
+ var behavior = new ExpanderAnimationBehavior();
+ expander.Behaviors.Add(behavior);
+ Assert.IsType(expander.Behaviors[0]);
+ Assert.IsType(behavior);
+ Assert.IsType(expander.ExpansionController);
+ Assert.Same(behavior, expander.ExpansionController);
+ }
+
+ [Fact(Timeout = (int)TestDuration.Short)]
+ public async Task OnExpandingAsyncAnimatesHeightCorrectly()
+ {
+ var tcs = new TaskCompletionSource();
+ var expander = new Maui.Views.Expander();
+ expander.ExpandedChanged += (_, _) => tcs.TrySetResult();
+ var behavior = new ExpanderAnimationBehavior();
+ expander.Behaviors.Add(behavior);
+ expander.Header = new Label { Text = "Header" };
+ expander.Content = new Label { Text = "Hello" };
+ Assert.NotNull(expander.ContentHost);
+ expander.ContentHost.HeightRequest = 0;
+ expander.IsExpanded = true;
+ await tcs.Task;
+ var element = expander.Content as VisualElement;
+ Assert.True(expander.IsExpanded);
+ Assert.Equal(-1, expander.ContentHost.HeightRequest);
+ Assert.NotNull(element);
+ Assert.True(element.IsVisible);
+ }
+
+ [Fact]
+ public void ExpanderContentHostIsUnsetWhenContentIsRemoved()
+ {
+ var expander = new Expander();
+ expander.Content = new Label { Text = "Hello" };
+ Assert.NotNull(expander.ContentHost);
+ expander.SetValue(Expander.ContentProperty, null);
+ Assert.Null(expander.ContentHost);
+ }
+
+ class MockExpansionController : IExpansionController
+ {
+ public event EventHandler? Expanding;
+ public event EventHandler? Collapsing;
+
+ public int ExpandingCount { get; private set; } = 0;
+ public int CollapsingCount { get; private set; } = 0;
+
+ public async Task OnExpandingAsync(Expander expander)
+ {
+ await Task.Yield();
+ ExpandingCount++;
+ Expanding?.Invoke(this, EventArgs.Empty);
+ }
+
+ public async Task OnCollapsingAsync(Expander expander)
+ {
+ await Task.Yield();
+ CollapsingCount++;
+ Collapsing?.Invoke(this, EventArgs.Empty);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Behaviors/ExpanderAnimationBehavior.shared.cs b/src/CommunityToolkit.Maui/Behaviors/ExpanderAnimationBehavior.shared.cs
new file mode 100644
index 0000000000..86d826b36e
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Behaviors/ExpanderAnimationBehavior.shared.cs
@@ -0,0 +1,93 @@
+using CommunityToolkit.Maui.Core;
+
+namespace CommunityToolkit.Maui.Behaviors;
+
+///
+/// A behavior that adds smooth expand and collapse animations to an .
+///
+public partial class ExpanderAnimationBehavior : BaseBehavior, IExpansionController
+{
+ ///
+ /// Gets or sets the easing function used when the expander collapses.
+ ///
+ [BindableProperty]
+ public partial Easing CollapsingEasing { get; set; } = ExpanderAnimationBehaviorDefaults.CollapsingEasing;
+
+ ///
+ /// Gets or sets the duration, in milliseconds, of the collapse animation.
+ ///
+ [BindableProperty]
+ public partial uint CollapsingLength { get; set; } = ExpanderAnimationBehaviorDefaults.CollapsingLength;
+
+ ///
+ /// Gets or sets the easing function used when the expander expands.
+ ///
+ [BindableProperty]
+ public partial Easing ExpandingEasing { get; set; } = ExpanderAnimationBehaviorDefaults.ExpandingEasing;
+
+ ///
+ /// Gets or sets the duration, in milliseconds, of the expansion animation.
+ ///
+ [BindableProperty]
+ public partial uint ExpandingLength { get; set; } = ExpanderAnimationBehaviorDefaults.ExpandingLength;
+
+ IExpansionController? previousController;
+
+ ///
+ /// Attaches the behavior to the specified expander and assigns it as the controller responsible for handling expansion animations.
+ ///
+ /// The Expander control to which the behavior is being attached to.
+ protected override void OnAttachedTo(Views.Expander bindable)
+ {
+ base.OnAttachedTo(bindable);
+ previousController = bindable.ExpansionController;
+ bindable.ExpansionController = this;
+ }
+
+ ///
+ /// Detaches the behavior from the specified Expander control and restores its previous expansion controller.
+ ///
+ /// The Expander control from which the behavior is being detached.
+ protected override void OnDetachingFrom(Views.Expander bindable)
+ {
+ base.OnDetachingFrom(bindable);
+ if (bindable.ExpansionController == this)
+ {
+ bindable.ExpansionController = previousController;
+ }
+ }
+
+ ///
+ /// Performs the animation that runs when the expander transitions from a collapsed to an expanded state.
+ ///
+ /// The Expander control that is expanding.
+ public async Task OnExpandingAsync(Views.Expander expander)
+ {
+ if (expander.ContentHost is ContentView host && expander.Content is View view)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var size = view.Measure(host.Width, double.PositiveInfinity);
+ var animation = new Animation(v => host.HeightRequest = v, 0, size.Height);
+ animation.Commit(expander, "ExpanderAnimation", 16, ExpandingLength, ExpandingEasing, (v, c) => tcs.TrySetResult());
+ await tcs.Task;
+ host.HeightRequest = -1;
+ }
+ }
+
+ ///
+ /// Performs the animation that runs when the expander transitions from an expanded to a collapsed state.
+ ///
+ /// The Expander control that is collapsing.
+ public async Task OnCollapsingAsync(Views.Expander expander)
+ {
+ if (expander.ContentHost is ContentView host && expander.Content is View view)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var size = view.Measure(host.Width, double.PositiveInfinity);
+ var animation = new Animation(v => host.HeightRequest = v, size.Height, 0);
+ animation.Commit(expander, "ExpanderAnimation", 16, CollapsingLength, CollapsingEasing, (v, c) => tcs.TrySetResult());
+ await tcs.Task;
+ host.HeightRequest = 0;
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Maui/Interfaces/IExpansionController.shared.cs b/src/CommunityToolkit.Maui/Interfaces/IExpansionController.shared.cs
new file mode 100644
index 0000000000..01d1f2a696
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Interfaces/IExpansionController.shared.cs
@@ -0,0 +1,24 @@
+namespace CommunityToolkit.Maui;
+
+///
+/// Defines a pluggable controller responsible for handling expansion
+/// and collapse transitions for an .
+///
+public interface IExpansionController
+{
+ ///
+ /// Executes asynchronous logic when the expander transitions
+ /// from a collapsed to an expanded state.
+ ///
+ /// The instance initiating the expansion.
+ /// A task representing the asynchronous operation.
+ Task OnExpandingAsync(Views.Expander expander);
+
+ ///
+ /// Executes asynchronous logic when the expander transitions
+ /// from an expanded to a collapsed state.
+ ///
+ /// The instance initiating the collapse.
+ /// A task representing the asynchronous operation.
+ Task OnCollapsingAsync(Views.Expander expander);
+}
diff --git a/src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs b/src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs
index ed1f67f9c5..d18c444ae7 100644
--- a/src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs
+++ b/src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs
@@ -10,7 +10,8 @@ namespace CommunityToolkit.Maui.Views;
[RequiresUnreferencedCode("Calls Microsoft.Maui.Controls.Binding.Binding(String, BindingMode, IValueConverter, Object, String, Object)")]
public partial class Expander : ContentView, IExpander
{
- readonly WeakEventManager tappedEventManager = new();
+ readonly WeakEventManager expandedChangingEventManager = new();
+ readonly WeakEventManager expandedChangedEventManager = new();
///
/// Initialize a new instance of .
@@ -30,13 +31,22 @@ public Expander()
};
}
+ ///
+ /// Triggered when the expander is about to change
+ ///
+ public event EventHandler ExpandedChanging
+ {
+ add => expandedChangingEventManager.AddEventHandler(value);
+ remove => expandedChangingEventManager.RemoveEventHandler(value);
+ }
+
///
/// Triggered when the value of changes.
///
public event EventHandler ExpandedChanged
{
- add => tappedEventManager.AddEventHandler(value);
- remove => tappedEventManager.RemoveEventHandler(value);
+ add => expandedChangedEventManager.AddEventHandler(value);
+ remove => expandedChangedEventManager.RemoveEventHandler(value);
}
///
@@ -60,7 +70,7 @@ public event EventHandler ExpandedChanged
///
/// Gets or sets a value indicating whether the expander is expanded.
///
- [BindableProperty(PropertyChangedMethodName = nameof(OnIsExpandedPropertyChanged))]
+ [BindableProperty(PropertyChangingMethodName = nameof(OnIsExpandedPropertyChanging), PropertyChangedMethodName = nameof(OnIsExpandedPropertyChanged))]
public partial bool IsExpanded { get; set; }
///
@@ -75,6 +85,13 @@ public event EventHandler ExpandedChanged
[BindableProperty(PropertyChangedMethodName = nameof(OnHeaderPropertyChanged))]
public partial IView Header { get; set; }
+ ///
+ /// Gets or sets the component that performs the expansion and collapse
+ /// logic for this expander, including any optional animations.
+ ///
+ [BindableProperty]
+ public partial IExpansionController? ExpansionController { get; set; }
+
///
/// The Action that fires when is tapped.
/// By default, this runs .
@@ -88,6 +105,12 @@ public event EventHandler ExpandedChanged
Grid ContentGrid => (Grid)base.Content;
+ ///
+ /// Gets the that hosts the expander's content,
+ /// which can be used to apply animation or transition effects during expansion and collapse.
+ ///
+ public ContentView? ContentHost { get; internal set; }
+
static void OnExpandDirectionChanging(BindableObject bindable, object oldValue, object newValue)
{
var direction = (Expander)bindable;
@@ -102,13 +125,26 @@ static void OnExpandDirectionChanging(BindableObject bindable, object oldValue,
static void OnContentPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
var expander = (Expander)bindable;
+
+ if (expander.ContentHost is not null)
+ {
+ expander.ContentGrid.Remove(expander.ContentHost);
+ expander.ContentHost = null;
+ }
+
if (newValue is View view)
{
- view.SetBinding(IsVisibleProperty, new Binding(nameof(IsExpanded), source: expander));
+ // Initialize content visibility and height based on the current expansion state
+ view.IsVisible = expander.IsExpanded;
+ expander.ContentHost = new ContentView
+ {
+ Content = view,
+ IsClippedToBounds = true,
+ HeightRequest = expander.IsExpanded ? -1 : 0
+ };
- expander.ContentGrid.Remove(oldValue);
- expander.ContentGrid.Add(newValue);
- expander.ContentGrid.SetRow(view, expander.Direction switch
+ expander.ContentGrid.Add(expander.ContentHost);
+ expander.ContentGrid.SetRow(expander.ContentHost, expander.Direction switch
{
ExpandDirection.Down => 1,
ExpandDirection.Up => 0,
@@ -136,6 +172,13 @@ static void OnHeaderPropertyChanged(BindableObject bindable, object oldValue, ob
}
}
+ static void OnIsExpandedPropertyChanging(BindableObject bindable, object oldValue, object newValue)
+ {
+ Expander expander = (Expander)bindable;
+ expander.expansionGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ expander.expandedChangingEventManager.HandleEvent(expander, new ExpandedChangingEventArgs((bool)oldValue, (bool)newValue), nameof(ExpandedChanging));
+ }
+
static void OnIsExpandedPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
((IExpander)bindable).ExpandedChanged(((IExpander)bindable).IsExpanded);
@@ -146,7 +189,7 @@ static void OnDirectionPropertyChanged(BindableObject bindable, object oldValue,
void HandleDirectionChanged(ExpandDirection expandDirection)
{
- if (Header is null || Content is null)
+ if (Header is null || ContentHost is null)
{
return;
}
@@ -155,12 +198,12 @@ void HandleDirectionChanged(ExpandDirection expandDirection)
{
case ExpandDirection.Down:
ContentGrid.SetRow(Header, 0);
- ContentGrid.SetRow(Content, 1);
+ ContentGrid.SetRow(ContentHost, 1);
break;
case ExpandDirection.Up:
ContentGrid.SetRow(Header, 1);
- ContentGrid.SetRow(Content, 0);
+ ContentGrid.SetRow(ContentHost, 0);
break;
default:
@@ -181,7 +224,18 @@ void OnHeaderTapGestureRecognizerTapped(object? sender, TappedEventArgs tappedEv
HandleHeaderTapped?.Invoke(tappedEventArgs);
}
+ TaskCompletionSource expansionGate = new();
+
void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs)
+ {
+ _ = Dispatcher.Dispatch(async () =>
+ {
+ await expansionGate.Task;
+ ResizeExpanderInItemsView2(tappedEventArgs);
+ });
+ }
+
+ void ResizeExpanderInItemsView2(TappedEventArgs tappedEventArgs)
{
if (Header is null)
{
@@ -189,11 +243,6 @@ void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs)
}
Element element = this;
-#if WINDOWS
- var size = IsExpanded
- ? Measure(double.PositiveInfinity, double.PositiveInfinity)
- : Header.Measure(double.PositiveInfinity, double.PositiveInfinity);
-#endif
while (element is not null)
{
#if IOS || MACCATALYST
@@ -208,11 +257,6 @@ void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs)
{
cell.ForceUpdateSize();
}
- else if (element is CollectionView collectionView)
- {
- var tapLocation = tappedEventArgs.GetPosition(collectionView);
- ForceUpdateCellSize(collectionView, size, tapLocation);
- }
#endif
element = element.Parent;
@@ -221,11 +265,48 @@ void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs)
void IExpander.ExpandedChanged(bool isExpanded)
{
+ _ = Dispatcher.Dispatch(async () => await ExpandedChangedAsync(!isExpanded, isExpanded));
+ }
+
+ async Task ExpandedChangedAsync(bool wasExpanded, bool isExpanded)
+ {
+ try
+ {
+ if (ContentHost is ContentView host && Content is View view)
+ {
+ IExpansionController controller = ExpansionController ?? InstantExpansionController.Instance;
+ if (!wasExpanded && isExpanded)
+ {
+ view.IsVisible = true;
+ await controller.OnExpandingAsync(this);
+ host.HeightRequest = -1;
+ }
+ else
+ {
+ await controller.OnCollapsingAsync(this);
+ host.HeightRequest = 0;
+ view.IsVisible = false;
+ }
+ }
+ }
+ finally
+ {
+ expansionGate.TrySetResult();
+ }
+
if (Command?.CanExecute(CommandParameter) is true)
{
Command.Execute(CommandParameter);
}
- tappedEventManager.HandleEvent(this, new ExpandedChangedEventArgs(isExpanded), nameof(ExpandedChanged));
+ expandedChangedEventManager.HandleEvent(this, new ExpandedChangedEventArgs(isExpanded), nameof(ExpandedChanged));
}
-}
\ No newline at end of file
+}
+
+sealed class InstantExpansionController : IExpansionController
+{
+ public static InstantExpansionController Instance { get; } = new();
+ InstantExpansionController() { }
+ public Task OnExpandingAsync(Expander expander) => Task.CompletedTask;
+ public Task OnCollapsingAsync(Expander expander) => Task.CompletedTask;
+}