diff --git a/samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs b/samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs index 4505c11e95..03ffb44b4a 100644 --- a/samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs @@ -216,7 +216,6 @@ namespace ControlCatalog.ViewModels { var animation = new Animation { - FillMode = FillMode.Forward, Children = { new KeyFrame @@ -247,7 +246,6 @@ namespace ControlCatalog.ViewModels to.IsVisible = true; var animation = new Animation { - FillMode = FillMode.Forward, Children = { new KeyFrame diff --git a/src/Avalonia.Controls/ContentControl.cs b/src/Avalonia.Controls/ContentControl.cs index d47a7a7809..f30920ed2e 100644 --- a/src/Avalonia.Controls/ContentControl.cs +++ b/src/Avalonia.Controls/ContentControl.cs @@ -11,7 +11,7 @@ using Avalonia.Metadata; namespace Avalonia.Controls { /// - /// Displays according to a . + /// Displays according to an . /// [TemplatePart("PART_ContentPresenter", typeof(IContentPresenter))] public class ContentControl : TemplatedControl, IContentControl, IContentPresenterHost diff --git a/src/Avalonia.Controls/TransitioningContentControl.cs b/src/Avalonia.Controls/TransitioningContentControl.cs index 2e0a36ad19..edc1b10d93 100644 --- a/src/Avalonia.Controls/TransitioningContentControl.cs +++ b/src/Avalonia.Controls/TransitioningContentControl.cs @@ -1,33 +1,30 @@ using System; using System.Threading; +using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; -using Avalonia.Threading; +using Avalonia.Data; namespace Avalonia.Controls; /// -/// Displays according to a . -/// Uses to move between the old and new content values. +/// Displays according to an , +/// using a to move between the old and new content. /// public class TransitioningContentControl : ContentControl { - private CancellationTokenSource? _lastTransitionCts; - private object? _currentContent; + private CancellationTokenSource? _currentTransition; + private ContentPresenter? _transitionPresenter; + private Optional _transitionFrom; /// /// Defines the property. /// public static readonly StyledProperty PageTransitionProperty = - AvaloniaProperty.Register(nameof(PageTransition), - new CrossFade(TimeSpan.FromSeconds(0.125))); - - /// - /// Defines the property. - /// - public static readonly DirectProperty CurrentContentProperty = - AvaloniaProperty.RegisterDirect(nameof(CurrentContent), - o => o.CurrentContent); + AvaloniaProperty.Register( + nameof(PageTransition), + defaultValue: new ImmutableCrossFade(TimeSpan.FromMilliseconds(125))); /// /// Gets or sets the animation played when content appears and disappears. @@ -38,74 +35,79 @@ public class TransitioningContentControl : ContentControl set => SetValue(PageTransitionProperty, value); } - /// - /// Gets the content currently displayed on the screen. - /// - public object? CurrentContent + protected override Size ArrangeOverride(Size finalSize) { - get => _currentContent; - private set => SetAndRaise(CurrentContentProperty, ref _currentContent, value); - } + var result = base.ArrangeOverride(finalSize); - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); + if (_transitionFrom.HasValue) + { + _currentTransition?.Cancel(); + + if (_transitionPresenter is not null && + Presenter is Visual presenter && + PageTransition is { } transition && + (_transitionFrom.Value is not Visual v || v.VisualParent is null)) + { + _transitionPresenter.Content = _transitionFrom.Value; + _transitionPresenter.IsVisible = true; + _transitionFrom = Optional.Empty; + + var cancel = new CancellationTokenSource(); + _currentTransition = cancel; + + transition.Start(_transitionPresenter, presenter, true, cancel.Token).ContinueWith(x => + { + if (!cancel.IsCancellationRequested) + { + _transitionPresenter.Content = null; + _transitionPresenter.IsVisible = false; + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + + _transitionFrom = Optional.Empty; + } - Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content)); + return result; } - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + protected override bool RegisterContentPresenter(IContentPresenter presenter) { - base.OnDetachedFromVisualTree(e); - - _lastTransitionCts?.Cancel(); + if (!base.RegisterContentPresenter(presenter) && + presenter is ContentPresenter p && + p.Name == "PART_TransitionContentPresenter") + { + _transitionPresenter = p; + _transitionPresenter.IsVisible = false; + return true; + } + + return false; } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); - if (change.Property == ContentProperty) + if (change.Property == ContentProperty && + _transitionPresenter is not null && + Presenter is Visual && + PageTransition is not null) { - Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content)); - } - else if (change.Property == CurrentContentProperty) - { - UpdateLogicalTree(change.OldValue, change.NewValue); + _transitionFrom = change.GetOldValue(); + InvalidateArrange(); } } - protected override void ContentChanged(AvaloniaPropertyChangedEventArgs e) + private class ImmutableCrossFade : IPageTransition { - // We do nothing becuse we should not remove old Content until the animation is over - } + private readonly CrossFade _inner; - /// - /// Updates the content with transitions. - /// - /// New content to set. - private async void UpdateContentWithTransition(object? content) - { - if (VisualRoot is null) - { - return; - } - - _lastTransitionCts?.Cancel(); - _lastTransitionCts = new CancellationTokenSource(); - var localToken = _lastTransitionCts.Token; - - if (PageTransition != null) - await PageTransition.Start(this, null, true, localToken); + public ImmutableCrossFade(TimeSpan duration) => _inner = new CrossFade(duration); - if (localToken.IsCancellationRequested) + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { - return; + return _inner.Start(from, to, cancellationToken); } - - CurrentContent = content; - - if (PageTransition != null) - await PageTransition.Start(null, this, true, localToken); } } diff --git a/src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml b/src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml index f1971fe236..e53fc1aad4 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml @@ -3,16 +3,27 @@ - + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml b/src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml index 63185e11e6..fa8d749b1f 100644 --- a/src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml @@ -5,16 +5,27 @@ - + + + + diff --git a/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs b/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs index aaa1de4da4..77fc207554 100644 --- a/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs @@ -1,66 +1,243 @@ using System; -using Avalonia.LogicalTree; -using Avalonia.UnitTests; -using Xunit; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Headless; +using Avalonia.Layout; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +#nullable enable namespace Avalonia.Controls.UnitTests { public class TransitioningContentControlTests { [Fact] - public void Old_Content_Shuold_Be_Removed__From_Logical_Tree_After_Out_Animation() + public void Transition_Should_Not_Be_Run_When_First_Shown() + { + using var app = Start(); + var (target, transition) = CreateTarget("foo"); + + Assert.Equal(0, transition.StartCount); + } + + [Fact] + public void TransitionContentPresenter_Should_Initially_Be_Hidden() + { + using var app = Start(); + var (target, transition) = CreateTarget("foo"); + var transitionPresenter = GetTransitionContentPresenter(target); + + Assert.False(transitionPresenter.IsVisible); + } + + [Fact] + public void Transition_Should_Be_Run_On_Layout() + { + using var app = Start(); + var (target, transition) = CreateTarget("foo"); + + target.Content = "bar"; + Assert.Equal(0, transition.StartCount); + + Layout(target); + Assert.Equal(1, transition.StartCount); + } + + [Fact] + public void Control_Transition_Should_Be_Run_On_Layout() + { + using var app = Start(); + var (target, transition) = CreateTarget(new Button()); + + target.Content = new Canvas(); + Assert.Equal(0, transition.StartCount); + + Layout(target); + Assert.Equal(1, transition.StartCount); + } + + [Fact] + public void ContentPresenters_Should_Be_Setup_For_Transition() + { + using var app = Start(); + var (target, transition) = CreateTarget("foo"); + var transitionPresenter = GetTransitionContentPresenter(target); + + target.Content = "bar"; + Layout(target); + + Assert.True(transitionPresenter.IsVisible); + Assert.Equal("bar", target.Presenter!.Content); + Assert.Equal("foo", transitionPresenter.Content); + } + + [Fact] + public void TransitionContentPresenter_Should_Be_Hidden_When_Transition_Completes() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var (target, transition) = CreateTarget("foo"); + var transitionPresenter = GetTransitionContentPresenter(target); + + target.Content = "bar"; + Layout(target); + Assert.True(transitionPresenter.IsVisible); + + transition.Complete(); + sync.ExecutePostedCallbacks(); + + Assert.False(transitionPresenter.IsVisible); + } + + [Fact] + public void Transition_Should_Be_Canceled_If_Content_Changes_While_Running() { - using (UnitTestApplication.Start(TestServices.StyledWindow)) + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var (target, transition) = CreateTarget("foo"); + var transitionPresenter = GetTransitionContentPresenter(target); + + target.Content = "bar"; + Layout(target); + target.Content = "baz"; + + Assert.Equal(0, transition.CancelCount); + + Layout(target); + + Assert.Equal(1, transition.CancelCount); + } + + [Fact] + public void New_Transition_Should_Be_Started_If_Content_Changes_While_Running() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var (target, transition) = CreateTarget("foo"); + var transitionPresenter = GetTransitionContentPresenter(target); + + target.Content = "bar"; + Layout(target); + + target.Content = "baz"; + + var startedRaised = 0; + + transition.Started += (from, to, forward) => { - var testTransition = new TestTransition(); + var fromPresenter = Assert.IsType(from); + var toPresenter = Assert.IsType(to); + + Assert.Same(transitionPresenter, fromPresenter); + Assert.Same(target.Presenter, toPresenter); + Assert.Equal("bar", fromPresenter.Content); + Assert.Equal("baz", toPresenter.Content); + Assert.True(forward); + Assert.Equal(1, transition.CancelCount); - var target = new TransitioningContentControl(); - target.PageTransition = testTransition; + ++startedRaised; + }; - var root = new TestRoot() { Child = target }; + Layout(target); + sync.ExecutePostedCallbacks(); + + Assert.Equal(1, startedRaised); + Assert.Equal("baz", target.Presenter!.Content); + Assert.Equal("bar", transitionPresenter.Content); + } - var oldControl = new Control(); - var newControl = new Control(); + private static IDisposable Start() + { + return UnitTestApplication.Start( + TestServices.MockThreadingInterface.With( + fontManagerImpl: new HeadlessFontManagerStub(), + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HeadlessTextShaperStub())); + } - target.Content = oldControl; - Threading.Dispatcher.UIThread.RunJobs(); + private static (TransitioningContentControl, TestTransition) CreateTarget(object content) + { + var transition = new TestTransition(); + var target = new TransitioningContentControl + { + Content = content, + PageTransition = transition, + Template = CreateTemplate(), + }; - Assert.Equal(target, oldControl.GetLogicalParent()); - Assert.Equal(null, newControl.GetLogicalParent()); + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + return (target, transition); + } - testTransition.BeginTransition += isFrom => + private static IControlTemplate CreateTemplate() + { + return new FuncControlTemplate((x, ns) => + { + return new Panel { - // Old out - if (isFrom) + Children = { - Assert.Equal(target, oldControl.GetLogicalParent()); - Assert.Equal(null, newControl.GetLogicalParent()); - } - // New in - else - { - Assert.Equal(null, oldControl.GetLogicalParent()); - Assert.Equal(target, newControl.GetLogicalParent()); + new ContentPresenter + { + Name = "PART_ContentPresenter", + [!ContentPresenter.ContentProperty] = x[!ContentControl.ContentProperty], + }, + new ContentPresenter + { + Name = "PART_TransitionContentPresenter", + }, } }; + }); + } - target.Content = newControl; - Threading.Dispatcher.UIThread.RunJobs(); - } + private static ContentPresenter GetTransitionContentPresenter(TransitioningContentControl target) + { + return Assert.IsType(target + .GetTemplateChildren() + .First(x => x.Name == "PART_TransitionContentPresenter")); } - } - public class TestTransition : IPageTransition - { - public event Action BeginTransition; - public Task Start(Visual from, Visual to, bool forward, CancellationToken cancellationToken) + private void Layout(Control c) { - bool isFrom = from != null && to == null; - BeginTransition?.Invoke(isFrom); - return Task.CompletedTask; + (c.GetVisualRoot() as ILayoutRoot)?.LayoutManager.ExecuteLayoutPass(); + } + + private class TestTransition : IPageTransition + { + private TaskCompletionSource? _tcs; + + public int StartCount { get; private set; } + public int FinishCount { get; private set; } + public int CancelCount { get; private set; } + + public event Action? Started; + + public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + ++StartCount; + Started?.Invoke(from, to, forward); + if (_tcs is not null) + throw new InvalidOperationException("Transition already running"); + _tcs = new TaskCompletionSource(); + cancellationToken.Register(() => _tcs.TrySetResult()); + await _tcs.Task; + _tcs = null; + + if (!cancellationToken.IsCancellationRequested) + ++FinishCount; + else + ++CancelCount; + } + + public void Complete() => _tcs!.TrySetResult(); } } }