diff --git a/src/Avalonia.Controls/TransitionCompletedEventArgs.cs b/src/Avalonia.Controls/TransitionCompletedEventArgs.cs new file mode 100644 index 0000000000..3ce4fa2dee --- /dev/null +++ b/src/Avalonia.Controls/TransitionCompletedEventArgs.cs @@ -0,0 +1,39 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Controls; + +/// +/// Represents the event arguments for . +/// +public class TransitionCompletedEventArgs : RoutedEventArgs +{ + /// + /// Initializes a new instance of . + /// + /// The content that was transitioned from. + /// The content that was transitioned to. + /// Whether the transition ran to completion. + public TransitionCompletedEventArgs(object? from, object? to, bool hasRunToCompletion) + : base(TransitioningContentControl.TransitionCompletedEvent) + { + From = from; + To = to; + HasRunToCompletion = hasRunToCompletion; + } + + /// + /// Gets the content that was transitioned from. + /// + public object? From { get; } + + /// + /// Gets the content that was transitioned to. + /// + public object? To { get; } + + /// + /// Gets whether the transition ran to completion. + /// If false, the transition may have completed instantly or been cancelled. + /// + public bool HasRunToCompletion { get; } +} diff --git a/src/Avalonia.Controls/TransitioningContentControl.cs b/src/Avalonia.Controls/TransitioningContentControl.cs index 619f7bd2f0..f6dc060a1b 100644 --- a/src/Avalonia.Controls/TransitioningContentControl.cs +++ b/src/Avalonia.Controls/TransitioningContentControl.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Avalonia.Animation; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; -using Avalonia.Data; +using Avalonia.Interactivity; namespace Avalonia.Controls; @@ -36,6 +36,14 @@ public class TransitioningContentControl : ContentControl nameof(IsTransitionReversed), defaultValue: false); + /// + /// Defines the routed event. + /// + public static readonly RoutedEvent TransitionCompletedEvent = + RoutedEvent.Register( + nameof(TransitionCompleted), + RoutingStrategies.Direct); + /// /// Gets or sets the animation played when content appears and disappears. /// @@ -55,6 +63,15 @@ public class TransitioningContentControl : ContentControl set => SetValue(IsTransitionReversedProperty, value); } + /// + /// Raised when the old content isn't needed anymore by the control, because the transition has completed. + /// + public event EventHandler TransitionCompleted + { + add => AddHandler(TransitionCompletedEvent, value); + remove => RemoveHandler(TransitionCompletedEvent, value); + } + protected override Size ArrangeOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); @@ -64,7 +81,7 @@ public class TransitioningContentControl : ContentControl _currentTransition?.Cancel(); if (_presenter2 is not null && - Presenter is Visual presenter && + Presenter is { } presenter && PageTransition is { } transition) { _shouldAnimate = false; @@ -74,9 +91,14 @@ public class TransitioningContentControl : ContentControl var from = _isFirstFull ? _presenter2 : presenter; var to = _isFirstFull ? presenter : _presenter2; + var fromContent = from.Content; + var toContent = to.Content; - transition.Start(from, to, !IsTransitionReversed, cancel.Token).ContinueWith(x => + transition.Start(from, to, !IsTransitionReversed, cancel.Token).ContinueWith(task => { + OnTransitionCompleted(new TransitionCompletedEventArgs( + fromContent, toContent, task.Status == TaskStatus.RanToCompletion && !cancel.IsCancellationRequested)); + if (!cancel.IsCancellationRequested) { HideOldPresenter(); @@ -134,13 +156,17 @@ public class TransitioningContentControl : ContentControl } var currentPresenter = _isFirstFull ? _presenter2 : Presenter; + var fromContent = _lastPresenter?.Content; + var toContent = Content; if (_lastPresenter != null && _lastPresenter != currentPresenter && - _lastPresenter.Content == Content) + _lastPresenter.Content == toContent) + { _lastPresenter.Content = null; + } - currentPresenter.Content = Content; + currentPresenter.Content = toContent; currentPresenter.IsVisible = true; _lastPresenter = currentPresenter; @@ -154,6 +180,7 @@ public class TransitioningContentControl : ContentControl else { HideOldPresenter(); + OnTransitionCompleted(new TransitionCompletedEventArgs(fromContent, toContent, false)); } } @@ -167,6 +194,9 @@ public class TransitioningContentControl : ContentControl } } + private void OnTransitionCompleted(TransitionCompletedEventArgs e) + => RaiseEvent(e); + private class ImmutableCrossFade : IPageTransition { private readonly CrossFade _inner; diff --git a/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs b/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs index 1bcc805f82..f92fc96c2b 100644 --- a/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -127,6 +128,36 @@ namespace Avalonia.Controls.UnitTests Assert.False(presenter2.IsVisible); } + [Fact] + public void TransitionCompleted_Should_Be_Raised_When_Content_Changes() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var (target, transition) = CreateTarget("foo"); + + var completedTransitions = new List(); + target.TransitionCompleted += (_, e) => completedTransitions.Add(e); + + target.Content = "bar"; + Layout(target); + VerifyCompletedTransitions(); + + transition.Complete(); + sync.ExecutePostedCallbacks(); + VerifyCompletedTransitions(new TransitionCompletedEventArgs("foo", "bar", true)); + + target.Content = "foo"; + Layout(target); + VerifyCompletedTransitions(new TransitionCompletedEventArgs("foo", "bar", true)); + + transition.Complete(); + sync.ExecutePostedCallbacks(); + VerifyCompletedTransitions(new("foo", "bar", true), new("bar", "foo", true)); + + void VerifyCompletedTransitions(params TransitionCompletedEventArgs[] expected) + => Assert.Equal(expected, completedTransitions, TransitionCompletedEventArgsComparer.Instance); + } + [Fact] public void Transition_Should_Be_Canceled_If_Content_Changes_While_Running() { @@ -183,6 +214,30 @@ namespace Avalonia.Controls.UnitTests Assert.Equal("bar", presenter2.Content); } + [Fact] + public void TransitionCompleted_Should_Be_Raised_If_Content_Changes_While_Running() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var (target, _) = CreateTarget("foo"); + + var completedTransitions = new List(); + target.TransitionCompleted += (_, e) => completedTransitions.Add(e); + + target.Content = "bar"; + Layout(target); + sync.ExecutePostedCallbacks(); + VerifyCompletedTransitions(); + + target.Content = "baz"; + Layout(target); + sync.ExecutePostedCallbacks(); + VerifyCompletedTransitions(new TransitionCompletedEventArgs("foo", "bar", false)); + + void VerifyCompletedTransitions(params TransitionCompletedEventArgs[] expected) + => Assert.Equal(expected, completedTransitions, TransitionCompletedEventArgsComparer.Instance); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -350,5 +405,24 @@ namespace Avalonia.Controls.UnitTests public void Complete() => _tcs!.TrySetResult(); } + + private sealed class TransitionCompletedEventArgsComparer : IEqualityComparer + { + public static TransitionCompletedEventArgsComparer Instance { get; } = new(); + + public bool Equals(TransitionCompletedEventArgs? x, TransitionCompletedEventArgs? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null) + return false; + + return x.From == y.From && x.To == y.To && x.HasRunToCompletion == y.HasRunToCompletion; + } + + public int GetHashCode(TransitionCompletedEventArgs obj) + => HashCode.Combine(obj.From, obj.To, obj.HasRunToCompletion); + } } }