From 5474d1e36047ae85a49fc826f2ec418c9f6b8914 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 May 2023 12:31:07 +0200 Subject: [PATCH 1/4] Refactor TransitioningContentControl. Now has two `ContentPresenter`s between which it transitions. Fixes #11167 --- src/Avalonia.Controls/ContentControl.cs | 2 +- .../TransitioningContentControl.cs | 124 ++++----- .../Controls/TransitioningContentControl.xaml | 31 ++- .../Controls/TransitioningContentControl.xaml | 31 ++- .../TransitioningContentControlTests.cs | 251 +++++++++++++++--- 5 files changed, 313 insertions(+), 126 deletions(-) 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..116b61d358 100644 --- a/src/Avalonia.Controls/TransitioningContentControl.cs +++ b/src/Avalonia.Controls/TransitioningContentControl.cs @@ -1,33 +1,28 @@ 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)); /// /// Gets or sets the animation played when content appears and disappears. @@ -38,74 +33,67 @@ public class TransitioningContentControl : ContentControl set => SetValue(PageTransitionProperty, value); } - /// - /// Gets the content currently displayed on the screen. - /// - public object? CurrentContent - { - get => _currentContent; - private set => SetAndRaise(CurrentContentProperty, ref _currentContent, value); - } - - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); - - Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content)); - } - - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnDetachedFromVisualTree(e); - - _lastTransitionCts?.Cancel(); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + protected override Size ArrangeOverride(Size finalSize) { - base.OnPropertyChanged(change); + var result = base.ArrangeOverride(finalSize); - if (change.Property == ContentProperty) - { - Dispatcher.UIThread.Post(() => UpdateContentWithTransition(Content)); - } - else if (change.Property == CurrentContentProperty) + if (_transitionFrom.HasValue) { - UpdateLogicalTree(change.OldValue, change.NewValue); + _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; } - } - protected override void ContentChanged(AvaloniaPropertyChangedEventArgs e) - { - // We do nothing becuse we should not remove old Content until the animation is over + return result; } - /// - /// Updates the content with transitions. - /// - /// New content to set. - private async void UpdateContentWithTransition(object? content) + protected override bool RegisterContentPresenter(IContentPresenter presenter) { - if (VisualRoot is null) + if (!base.RegisterContentPresenter(presenter) && + presenter is ContentPresenter p && + p.Name == "PART_TransitionContentPresenter") { - return; + _transitionPresenter = p; + _transitionPresenter.IsVisible = false; + return true; } - _lastTransitionCts?.Cancel(); - _lastTransitionCts = new CancellationTokenSource(); - var localToken = _lastTransitionCts.Token; + return false; + } - if (PageTransition != null) - await PageTransition.Start(this, null, true, localToken); + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); - if (localToken.IsCancellationRequested) + if (change.Property == ContentProperty && + _transitionPresenter is not null && + Presenter is Visual && + PageTransition is not null) { - return; + _transitionFrom = change.GetOldValue(); + InvalidateArrange(); } - - 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(); } } } From 5470c1272f7601ea96882373e0c4ce8ab56ce914 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 11 May 2023 12:39:24 +0200 Subject: [PATCH 2/4] Set default transition. Not really sure we should be doing this, but the previous version had it (though it was using a mutable object as a `StyledProperty` default value which means the instance was shared: bad). --- src/Avalonia.Controls/TransitioningContentControl.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Avalonia.Controls/TransitioningContentControl.cs b/src/Avalonia.Controls/TransitioningContentControl.cs index 116b61d358..943ceaf27c 100644 --- a/src/Avalonia.Controls/TransitioningContentControl.cs +++ b/src/Avalonia.Controls/TransitioningContentControl.cs @@ -24,6 +24,13 @@ public class TransitioningContentControl : ContentControl public static readonly StyledProperty PageTransitionProperty = AvaloniaProperty.Register(nameof(PageTransition)); + [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1012", + Justification = "Default property values shouldn't be set with SetCurrentValue.")] + public TransitioningContentControl() + { + PageTransition = new CrossFade(TimeSpan.FromMilliseconds(125)); + } + /// /// Gets or sets the animation played when content appears and disappears. /// From 7882801e5f49b2881ab9cd798a739b92cecc17d0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 12 May 2023 11:41:05 +0200 Subject: [PATCH 3/4] Set default value for PageTransition. Create an immutable crossfade class which can safely be used as a default styled property value. --- .../TransitioningContentControl.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Controls/TransitioningContentControl.cs b/src/Avalonia.Controls/TransitioningContentControl.cs index 943ceaf27c..edc1b10d93 100644 --- a/src/Avalonia.Controls/TransitioningContentControl.cs +++ b/src/Avalonia.Controls/TransitioningContentControl.cs @@ -22,14 +22,9 @@ public class TransitioningContentControl : ContentControl /// Defines the property. /// public static readonly StyledProperty PageTransitionProperty = - AvaloniaProperty.Register(nameof(PageTransition)); - - [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1012", - Justification = "Default property values shouldn't be set with SetCurrentValue.")] - public TransitioningContentControl() - { - PageTransition = new CrossFade(TimeSpan.FromMilliseconds(125)); - } + AvaloniaProperty.Register( + nameof(PageTransition), + defaultValue: new ImmutableCrossFade(TimeSpan.FromMilliseconds(125))); /// /// Gets or sets the animation played when content appears and disappears. @@ -103,4 +98,16 @@ public class TransitioningContentControl : ContentControl InvalidateArrange(); } } + + private class ImmutableCrossFade : IPageTransition + { + private readonly CrossFade _inner; + + public ImmutableCrossFade(TimeSpan duration) => _inner = new CrossFade(duration); + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + return _inner.Start(from, to, cancellationToken); + } + } } From e5eee5ac3cab7905beaa524de55ffece98e6fc55 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 13 May 2023 11:30:47 +0200 Subject: [PATCH 4/4] Fix custom transition. Don't set `FillMode` because the value will get stuck. --- .../ViewModels/TransitioningContentControlPageViewModel.cs | 2 -- 1 file changed, 2 deletions(-) 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