-
Notifications
You must be signed in to change notification settings - Fork 493
Add ExpanderAnimationBehavior, IExpansionController #3124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
50f76f4
b828c27
acdc9e2
18803ea
71888e6
4186bbd
c38ce1c
b8e5785
8036ab4
4289947
61e34c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// 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; | ||
| } | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <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; | ||
| } | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <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()); | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| host.HeightRequest = -1; | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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; | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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); | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"/>. | ||
|
|
@@ -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); | ||
| } | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <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> | ||
|
|
@@ -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; | ||
|
|
||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// <summary> | ||
| /// The Action that fires when <see cref="Header"/> is tapped. | ||
| /// By default, this <see cref="Action"/> runs <see cref="ResizeExpanderInItemsView(TappedEventArgs)"/>. | ||
|
|
@@ -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; } | ||
|
|
||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| static void OnExpandDirectionChanging(BindableObject bindable, object oldValue, object newValue) | ||
| { | ||
| var direction = (Expander)bindable; | ||
|
|
@@ -104,11 +127,13 @@ | |
| var expander = (Expander)bindable; | ||
| if (newValue is View view) | ||
| { | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 }; | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| expander.ContentGrid.Add(expander.ContentHost); | ||
| expander.ContentGrid.SetRow(expander.ContentHost, expander.Direction switch | ||
| { | ||
| ExpandDirection.Down => 1, | ||
| ExpandDirection.Up => 0, | ||
|
|
@@ -181,7 +206,19 @@ | |
| HandleHeaderTapped?.Invoke(tappedEventArgs); | ||
| } | ||
|
|
||
| TaskCompletionSource resizeTCS = new(); | ||
|
|
||
| void ResizeExpanderInItemsView(TappedEventArgs tappedEventArgs) | ||
| { | ||
| _ = Dispatcher.Dispatch(async () => | ||
| { | ||
| resizeTCS = new(); | ||
| await resizeTCS.Task; | ||
| ResizeExpanderInItemsView2(tappedEventArgs); | ||
| }); | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Comment on lines
+231
to
+235
|
||
| } | ||
|
|
||
| void ResizeExpanderInItemsView2(TappedEventArgs tappedEventArgs) | ||
| { | ||
| if (Header is null) | ||
| { | ||
|
|
@@ -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
|
||
| { | ||
| (listView.Handler?.PlatformView as UIKit.UITableView)?.ReloadData(); | ||
| } | ||
| #endif | ||
|
|
||
| #if WINDOWS | ||
| if (element.Parent is ListView listView && element is Cell cell) | ||
| { | ||
| cell.ForceUpdateSize(); | ||
| } | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| else if (element is CollectionView collectionView) | ||
| { | ||
| var tapLocation = tappedEventArgs.GetPosition(collectionView); | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ForceUpdateCellSize(collectionView, size, tapLocation); | ||
| } | ||
| #endif | ||
|
|
||
|
|
@@ -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; | ||
| } | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| else | ||
| { | ||
| await ExpansionController.OnCollapsingAsync(this); | ||
| host.HeightRequest = 0; | ||
| view.IsVisible = false; | ||
| } | ||
| } | ||
|
|
||
| resizeTCS.TrySetResult(); | ||
|
|
||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
stephenquan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| public static InstantExpansionController Instance { get; } = new(); | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| public Task OnExpandingAsync(Expander expander) => Task.CompletedTask; | ||
| public Task OnCollapsingAsync(Expander expander) => Task.CompletedTask; | ||
| } | ||
stephenquan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
There was a problem hiding this comment.
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 theExpander, 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.