Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -54,10 +68,16 @@
<CollectionView.ItemTemplate>
<DataTemplate>
<mct:Expander x:DataType="sample:ContentCreator"
ExpandedChanging="Expander_ExpandedChanging"
ExpandedChanged="Expander_ExpandedChanged">
<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 +103,8 @@
<CollectionView.ItemTemplate>
<DataTemplate>
<mct:Expander x:DataType="sample:ContentCreator"
HeightRequest="180"
ExpandedChanging="Expander_ExpandedChanging"
ExpandedChanged="Expander_ExpandedChanged">
Comment on lines 104 to 106
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GridItemsLayout sample sets a hard-coded HeightRequest="180" on the Expander, which prevents the control from naturally resizing based on expanded/collapsed state and introduces an unexplained magic number. If this is required as a workaround for GridItemsLayout item sizing, consider documenting why in a comment (or remove the fixed height and apply the animation behavior here instead) so the sample demonstrates expected expander behavior.

Copilot uses AI. Check for mistakes.
<mct:Expander.Header>
<Label Text="{Binding Name}"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics;
using CommunityToolkit.Maui.Alerts;
using CommunityToolkit.Maui.Sample.ViewModels.Views;

Expand All @@ -11,10 +12,18 @@ public ExpanderPage(ExpanderViewModel viewModel) : base(viewModel)
InitializeComponent();
}

Stopwatch stopWatch = new();

void Expander_ExpandedChanging(object? sender, Core.ExpandedChangingEventArgs e)
{
stopWatch.Restart();
}

async void Expander_ExpandedChanged(object? sender, Core.ExpandedChangedEventArgs e)
{
stopWatch.Stop();
var collapsedText = e.IsExpanded ? "expanded" : "collapsed";
await Toast.Make($"Expander is {collapsedText}").Show(CancellationToken.None);
await Toast.Make($"Expander is {collapsedText} ({stopWatch.ElapsedMilliseconds} ms)").Show(CancellationToken.None);
}

async void GoToCSharpSampleClicked(object? sender, EventArgs? e)
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Maui.Views;

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;

/// <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);
bindable.ExpansionController = this;
}

/// <summary>
/// Detaches the behavior from the specified Expander control and resets its expansion controller to the shared instance.
/// </summary>
/// <param name="bindable">The Expander control from which the behavior is being detached from.</param>
protected override void OnDetachingFrom(Views.Expander bindable)
{
base.OnDetachingFrom(bindable);
bindable.ExpansionController = InstantExpansionController.Instance;
}

/// <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();
var size = view.Measure(host.Width, double.PositiveInfinity);
var animation = new Animation(v => host.HeightRequest = v, 0, size.Height);
animation.Commit(expander, "ExpandingAnimation", 16, ExpandingLength, ExpandingEasing, (v, c) => tcs.TrySetResult());
host.HeightRequest = -1;
await tcs.Task;
}
}

/// <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();
var size = view.Measure(host.Width, double.PositiveInfinity);
var animation = new Animation(v => host.HeightRequest = v, size.Height, 0);
animation.Commit(expander, "CollapsingAnimation", 16, CollapsingLength, CollapsingEasing, (v, c) => tcs.TrySetResult());
host.HeightRequest = 0;
await tcs.Task;
}
}
}
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);
}
90 changes: 79 additions & 11 deletions src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
[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();

/// <summary>
/// Initialize a new instance of <see cref="Expander"/>.
Expand All @@ -30,13 +31,22 @@
};
}

/// <summary>
/// Triggered when the expander is about to change
/// </summary>
public event EventHandler<ExpandedChangingEventArgs> ExpandedChanging
{
add => expandedChangingEventManager.AddEventHandler(value);
remove => expandedChangingEventManager.RemoveEventHandler(value);
}

/// <summary>
/// Triggered when the value of <see cref="IsExpanded"/> changes.
/// </summary>
public event EventHandler<ExpandedChangedEventArgs> ExpandedChanged
{
add => tappedEventManager.AddEventHandler(value);
remove => tappedEventManager.RemoveEventHandler(value);
add => expandedChangedEventManager.AddEventHandler(value);
remove => expandedChangedEventManager.RemoveEventHandler(value);
}

/// <summary>
Expand Down Expand Up @@ -75,6 +85,13 @@
[BindableProperty(PropertyChangedMethodName = nameof(OnHeaderPropertyChanged))]
public partial IView Header { get; set; }

/// <summary>
/// Gets or sets the component that performs the expansion and collapse
/// logic for this expander, including any optional animations.
/// </summary>
[BindableProperty]
public partial IExpansionController ExpansionController { get; set; } = InstantExpansionController.Instance;

/// <summary>
/// The Action that fires when <see cref="Header"/> is tapped.
/// By default, this <see cref="Action"/> runs <see cref="ResizeExpanderInItemsView(TappedEventArgs)"/>.
Expand All @@ -88,6 +105,12 @@

Grid ContentGrid => (Grid)base.Content;

/// <summary>
/// Gets the <see cref="ContentView"/> that hosts the expander's content,
/// which can be used to apply animation or transition effects during expansion and collapse.
/// </summary>
public ContentView? ContentHost { get; internal set; }

static void OnExpandDirectionChanging(BindableObject bindable, object oldValue, object newValue)
{
var direction = (Expander)bindable;
Expand All @@ -104,11 +127,13 @@
var expander = (Expander)bindable;
if (newValue is View view)
{
view.SetBinding(IsVisibleProperty, new Binding(nameof(IsExpanded), source: expander));

expander.ContentGrid.Remove(oldValue);
expander.ContentGrid.Add(newValue);
expander.ContentGrid.SetRow(view, expander.Direction switch
if (expander.ContentHost is not null)
{
expander.ContentGrid.Remove(expander.ContentHost);
}
expander.ContentHost = new ContentView { Content = view, IsClippedToBounds = true, HeightRequest = 0 };
expander.ContentGrid.Add(expander.ContentHost);
expander.ContentGrid.SetRow(expander.ContentHost, expander.Direction switch
{
ExpandDirection.Down => 1,
ExpandDirection.Up => 0,
Expand Down Expand Up @@ -181,7 +206,19 @@
HandleHeaderTapped?.Invoke(tappedEventArgs);
}

TaskCompletionSource resizeTCS = new();

void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs)
{
_ = Dispatcher.Dispatch(async () =>
{
resizeTCS = new();
await resizeTCS.Task;
ResizeExpanderInItemsView2(tappedEventArgs);
});
Comment on lines +231 to +235
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResizeExpanderInItemsView awaits expansionGate.Task, but expansionGate is a mutable field that gets replaced on each IsExpanded change. With rapid toggles, this dispatched lambda can end up awaiting a different gate instance than the one completed by the corresponding transition, causing hangs/missed size updates. Capture the current gate/task into a local before dispatch/await, and ensure the same captured gate instance is the one completed by the matching expand/collapse transition.

Copilot uses AI. Check for mistakes.
}

void ResizeExpanderInItemsView2(TappedEventArgs tappedEventArgs)
{
if (Header is null)
{
Expand All @@ -197,21 +234,20 @@
while (element is not null)
{
#if IOS || MACCATALYST
if (element is ListView listView)

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-26)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'

Check warning on line 237 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'
{
(listView.Handler?.PlatformView as UIKit.UITableView)?.ReloadData();
}
#endif

#if WINDOWS
if (element.Parent is ListView listView && element is Cell cell)

Check warning on line 244 in src/CommunityToolkit.Maui/Views/Expander/Expander.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'
{
cell.ForceUpdateSize();
}
else if (element is CollectionView collectionView)
{
var tapLocation = tappedEventArgs.GetPosition(collectionView);
ForceUpdateCellSize(collectionView, size, tapLocation);
}
#endif

Expand All @@ -221,11 +257,43 @@

void IExpander.ExpandedChanged(bool isExpanded)
{
_ = Dispatcher.Dispatch(async () => await ExpandedChangedAsync(!isExpanded, isExpanded));
}

async Task ExpandedChangedAsync(bool wasExpanded, bool isExpanded)
{
expandedChangingEventManager.HandleEvent(this, new ExpandedChangingEventArgs(wasExpanded, isExpanded), nameof(ExpandedChanging));

if (ContentHost is ContentView host && Content is View view)
{
if (!wasExpanded && isExpanded)
{
view.IsVisible = true;
await ExpansionController.OnExpandingAsync(this);
host.HeightRequest = -1;
}
else
{
await ExpansionController.OnCollapsingAsync(this);
host.HeightRequest = 0;
view.IsVisible = false;
}
}

resizeTCS.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));
}
}
}

class InstantExpansionController : IExpansionController
{
public static InstantExpansionController Instance { get; } = new();
public Task OnExpandingAsync(Expander expander) => Task.CompletedTask;
public Task OnCollapsingAsync(Expander expander) => Task.CompletedTask;
}
Loading