Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<Label Text="Simple Expander (Tap Me)" FontSize="16" FontAttributes="Bold"/>
</mct:Expander.Header>

<mct:Expander.Behaviors>
<mct:ExpanderAnimationBehavior />
</mct:Expander.Behaviors>

<mct:Expander.Content>
<VerticalStackLayout>
<Label Text="Item 1"/>
Expand All @@ -36,11 +40,21 @@
<mct:Expander.Header>
<Label Text="Multi-Level Expander (Tap Me)" FontSize="16" FontAttributes="Bold"/>
</mct:Expander.Header>

<mct:Expander.Behaviors>
<mct:ExpanderAnimationBehavior />
</mct:Expander.Behaviors>

<mct:Expander.Content>
<mct:Expander Direction="Down" BackgroundColor="LightGray">
<mct:Expander.Header>
<Label Text="Nested Expander (Tap Me)" FontSize="14" FontAttributes="Bold"/>
</mct:Expander.Header>

<mct:Expander.Behaviors>
<mct:ExpanderAnimationBehavior />
</mct:Expander.Behaviors>

<mct:Expander.Content>
<Label Text="Item 1" />
</mct:Expander.Content>
Expand All @@ -58,6 +72,11 @@
<mct:Expander.Header>
<Label Text="{Binding Name}"/>
</mct:Expander.Header>

<mct:Expander.Behaviors>
<mct:ExpanderAnimationBehavior />
</mct:Expander.Behaviors>

<mct:Expander.Content>
<VerticalStackLayout>
<Label Text="{Binding Resource}" HorizontalOptions="Center"/>
Expand All @@ -83,6 +102,7 @@
<CollectionView.ItemTemplate>
<DataTemplate>
<mct:Expander x:DataType="sample:ContentCreator"
HeightRequest="180"
ExpandedChanged="Expander_ExpandedChanged">
<mct:Expander.Header>
<Label Text="{Binding Name}"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace CommunityToolkit.Maui.Core;

/// <summary>
/// Provides data for an event that occurs when an Expander is about to change its IsExpanded state.
/// </summary>
public class ExpandedChangingEventArgs(bool oldIsExpanded, bool newIsExpanded) : EventArgs
{
/// <summary>
/// True if expander was expanded before the change.
/// </summary>
public bool OldIsExpanded { get; } = oldIsExpanded;

/// <summary>
/// True if expander will be expanded after the change.
/// </summary>
public bool NewIsExpanded { get; } = newIsExpanded;
}
155 changes: 155 additions & 0 deletions src/CommunityToolkit.Maui.UnitTests/Views/Expander/ExpanderTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<ExpanderAnimationBehavior>(expander.Behaviors[0]);
Assert.IsType<ExpanderAnimationBehavior>(behavior);
Assert.IsType<ExpanderAnimationBehavior>(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using CommunityToolkit.Maui.Core;

namespace CommunityToolkit.Maui.Behaviors;

/// <summary>
/// A behavior that adds smooth expand and collapse animations to an <see cref="Views.Expander"/>.
/// </summary>
public partial class ExpanderAnimationBehavior : BaseBehavior<Views.Expander>, IExpansionController
{
/// <summary>
/// Gets or sets the easing function used when the expander collapses.
/// </summary>
[BindableProperty]
public partial Easing CollapsingEasing { get; set; } = ExpanderAnimationBehaviorDefaults.CollapsingEasing;

/// <summary>
/// Gets or sets the duration, in milliseconds, of the collapse animation.
/// </summary>
[BindableProperty]
public partial uint CollapsingLength { get; set; } = ExpanderAnimationBehaviorDefaults.CollapsingLength;

/// <summary>
/// Gets or sets the easing function used when the expander expands.
/// </summary>
[BindableProperty]
public partial Easing ExpandingEasing { get; set; } = ExpanderAnimationBehaviorDefaults.ExpandingEasing;

/// <summary>
/// Gets or sets the duration, in milliseconds, of the expansion animation.
/// </summary>
[BindableProperty]
public partial uint ExpandingLength { get; set; } = ExpanderAnimationBehaviorDefaults.ExpandingLength;

IExpansionController? previousController;

/// <summary>
/// Attaches the behavior to the specified expander and assigns it as the controller responsible for handling expansion animations.
/// </summary>
/// <param name="bindable">The Expander control to which the behavior is being attached to.</param>
protected override void OnAttachedTo(Views.Expander bindable)
{
base.OnAttachedTo(bindable);
previousController = bindable.ExpansionController;
bindable.ExpansionController = this;
}

/// <summary>
/// Detaches the behavior from the specified Expander control and restores its previous expansion controller.
/// </summary>
/// <param name="bindable">The Expander control from which the behavior is being detached.</param>
protected override void OnDetachingFrom(Views.Expander bindable)
{
base.OnDetachingFrom(bindable);
if (bindable.ExpansionController == this)
{
bindable.ExpansionController = previousController;
}
}

/// <summary>
/// Performs the animation that runs when the expander transitions from a collapsed to an expanded state.
/// </summary>
/// <param name="expander">The Expander control that is expanding.</param>
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;
}
}

/// <summary>
/// Performs the animation that runs when the expander transitions from an expanded to a collapsed state.
/// </summary>
/// <param name="expander">The Expander control that is collapsing.</param>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace CommunityToolkit.Maui;

/// <summary>
/// Defines a pluggable controller responsible for handling expansion
/// and collapse transitions for an <see cref="Views.Expander"/>.
/// </summary>
public interface IExpansionController
{
/// <summary>
/// Executes asynchronous logic when the expander transitions
/// from a collapsed to an expanded state.
/// </summary>
/// <param name="expander">The <see cref="Views.Expander"/> instance initiating the expansion.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task OnExpandingAsync(Views.Expander expander);

/// <summary>
/// Executes asynchronous logic when the expander transitions
/// from an expanded to a collapsed state.
/// </summary>
/// <param name="expander">The <see cref="Views.Expander"/> instance initiating the collapse.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task OnCollapsingAsync(Views.Expander expander);
}
Loading
Loading