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);
+ }
}
}