浏览代码

Merge pull request #11326 from AvaloniaUI/fixes/11167-transitioningcontentcontrol

Refactor TransitioningContentControl
Max Katz 2 年之前
父节点
当前提交
3efec1fa6e

+ 0 - 2
samples/ControlCatalog/ViewModels/TransitioningContentControlPageViewModel.cs

@@ -216,7 +216,6 @@ namespace ControlCatalog.ViewModels
             {
             {
                 var animation = new Animation
                 var animation = new Animation
                 {
                 {
-                    FillMode = FillMode.Forward,
                     Children =
                     Children =
                     {
                     {
                         new KeyFrame
                         new KeyFrame
@@ -247,7 +246,6 @@ namespace ControlCatalog.ViewModels
                 to.IsVisible = true;
                 to.IsVisible = true;
                 var animation = new Animation
                 var animation = new Animation
                 {
                 {
-                    FillMode = FillMode.Forward,
                     Children =
                     Children =
                     {
                     {
                         new KeyFrame
                         new KeyFrame

+ 1 - 1
src/Avalonia.Controls/ContentControl.cs

@@ -11,7 +11,7 @@ using Avalonia.Metadata;
 namespace Avalonia.Controls
 namespace Avalonia.Controls
 {
 {
     /// <summary>
     /// <summary>
-    /// Displays <see cref="Content"/> according to a <see cref="FuncDataTemplate"/>.
+    /// Displays <see cref="Content"/> according to an <see cref="IDataTemplate"/>.
     /// </summary>
     /// </summary>
     [TemplatePart("PART_ContentPresenter", typeof(IContentPresenter))]
     [TemplatePart("PART_ContentPresenter", typeof(IContentPresenter))]
     public class ContentControl : TemplatedControl, IContentControl, IContentPresenterHost
     public class ContentControl : TemplatedControl, IContentControl, IContentPresenterHost

+ 64 - 62
src/Avalonia.Controls/TransitioningContentControl.cs

@@ -1,33 +1,30 @@
 using System;
 using System;
 using System.Threading;
 using System.Threading;
+using System.Threading.Tasks;
 using Avalonia.Animation;
 using Avalonia.Animation;
+using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Templates;
 using Avalonia.Controls.Templates;
-using Avalonia.Threading;
+using Avalonia.Data;
 
 
 namespace Avalonia.Controls;
 namespace Avalonia.Controls;
 
 
 /// <summary>
 /// <summary>
-/// Displays <see cref="ContentControl.Content"/> according to a <see cref="FuncDataTemplate"/>.
-/// Uses <see cref="PageTransition"/> to move between the old and new content values. 
+/// Displays <see cref="ContentControl.Content"/> according to an <see cref="IDataTemplate"/>,
+/// using a <see cref="PageTransition"/> to move between the old and new content. 
 /// </summary>
 /// </summary>
 public class TransitioningContentControl : ContentControl
 public class TransitioningContentControl : ContentControl
 {
 {
-    private CancellationTokenSource? _lastTransitionCts;
-    private object? _currentContent;
+    private CancellationTokenSource? _currentTransition;
+    private ContentPresenter? _transitionPresenter;
+    private Optional<object?> _transitionFrom;
 
 
     /// <summary>
     /// <summary>
     /// Defines the <see cref="PageTransition"/> property.
     /// Defines the <see cref="PageTransition"/> property.
     /// </summary>
     /// </summary>
     public static readonly StyledProperty<IPageTransition?> PageTransitionProperty =
     public static readonly StyledProperty<IPageTransition?> PageTransitionProperty =
-        AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(nameof(PageTransition),
-            new CrossFade(TimeSpan.FromSeconds(0.125)));
-    
-    /// <summary>
-    /// Defines the <see cref="CurrentContent"/> property.
-    /// </summary>
-    public static readonly DirectProperty<TransitioningContentControl, object?> CurrentContentProperty =
-        AvaloniaProperty.RegisterDirect<TransitioningContentControl, object?>(nameof(CurrentContent),
-            o => o.CurrentContent);
+        AvaloniaProperty.Register<TransitioningContentControl, IPageTransition?>(
+            nameof(PageTransition),
+            defaultValue: new ImmutableCrossFade(TimeSpan.FromMilliseconds(125)));
 
 
     /// <summary>
     /// <summary>
     /// Gets or sets the animation played when content appears and disappears.
     /// Gets or sets the animation played when content appears and disappears.
@@ -38,74 +35,79 @@ public class TransitioningContentControl : ContentControl
         set => SetValue(PageTransitionProperty, value);
         set => SetValue(PageTransitionProperty, value);
     }
     }
 
 
-    /// <summary>
-    /// Gets the content currently displayed on the screen.
-    /// </summary>
-    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<object?>.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<object?>.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)
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     {
     {
         base.OnPropertyChanged(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<object?>();
+            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;
 
 
-    /// <summary>
-    /// Updates the content with transitions.
-    /// </summary>
-    /// <param name="content">New content to set.</param>
-    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);
     }
     }
 }
 }

+ 21 - 10
src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml

@@ -3,16 +3,27 @@
   <ControlTheme x:Key="{x:Type TransitioningContentControl}" TargetType="TransitioningContentControl">
   <ControlTheme x:Key="{x:Type TransitioningContentControl}" TargetType="TransitioningContentControl">
     <Setter Property="Template">
     <Setter Property="Template">
       <ControlTemplate>
       <ControlTemplate>
-        <ContentPresenter Name="PART_ContentPresenter"
-                          Background="{TemplateBinding Background}"
-                          BorderBrush="{TemplateBinding BorderBrush}"
-                          BorderThickness="{TemplateBinding BorderThickness}"
-                          CornerRadius="{TemplateBinding CornerRadius}"
-                          ContentTemplate="{TemplateBinding ContentTemplate}"
-                          Content="{TemplateBinding CurrentContent}"
-                          Padding="{TemplateBinding Padding}"
-                          VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
-                          HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
+        <Panel>
+          <ContentPresenter Name="PART_ContentPresenter"
+                            Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}"
+                            ContentTemplate="{TemplateBinding ContentTemplate}"
+                            Content="{TemplateBinding Content}"
+                            Padding="{TemplateBinding Padding}"
+                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
+          <ContentPresenter Name="PART_TransitionContentPresenter"
+                            Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}"
+                            ContentTemplate="{TemplateBinding ContentTemplate}"
+                            Padding="{TemplateBinding Padding}"
+                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
+        </Panel>
       </ControlTemplate>
       </ControlTemplate>
     </Setter>
     </Setter>
   </ControlTheme>
   </ControlTheme>

+ 21 - 10
src/Avalonia.Themes.Simple/Controls/TransitioningContentControl.xaml

@@ -5,16 +5,27 @@
     <!--  Set Defaults  -->
     <!--  Set Defaults  -->
     <Setter Property="Template">
     <Setter Property="Template">
       <ControlTemplate>
       <ControlTemplate>
-        <ContentPresenter Name="PART_ContentPresenter"
-                          Padding="{TemplateBinding Padding}"
-                          HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
-                          VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
-                          Background="{TemplateBinding Background}"
-                          BorderBrush="{TemplateBinding BorderBrush}"
-                          BorderThickness="{TemplateBinding BorderThickness}"
-                          Content="{TemplateBinding CurrentContent}"
-                          ContentTemplate="{TemplateBinding ContentTemplate}"
-                          CornerRadius="{TemplateBinding CornerRadius}" />
+        <Panel>
+          <ContentPresenter Name="PART_ContentPresenter"
+                            Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}"
+                            ContentTemplate="{TemplateBinding ContentTemplate}"
+                            Content="{TemplateBinding Content}"
+                            Padding="{TemplateBinding Padding}"
+                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
+          <ContentPresenter Name="PART_TransitionContentPresenter"
+                            Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}"
+                            ContentTemplate="{TemplateBinding ContentTemplate}"
+                            Padding="{TemplateBinding Padding}"
+                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" />
+        </Panel>
       </ControlTemplate>
       </ControlTemplate>
     </Setter>
     </Setter>
   </ControlTheme>
   </ControlTheme>

+ 214 - 37
tests/Avalonia.Controls.UnitTests/TransitioningContentControlTests.cs

@@ -1,66 +1,243 @@
 using System;
 using System;
-using Avalonia.LogicalTree;
-using Avalonia.UnitTests;
-using Xunit;
+using System.Linq;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Avalonia.Animation;
 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
 namespace Avalonia.Controls.UnitTests
 {
 {
     public class TransitioningContentControlTests
     public class TransitioningContentControlTests
     {
     {
         [Fact]
         [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<ContentPresenter>(from);
+                var toPresenter = Assert.IsType<ContentPresenter>(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<ContentPresenter>(target
+                .GetTemplateChildren()
+                .First(x => x.Name == "PART_TransitionContentPresenter"));
         }
         }
-    }
-    public class TestTransition : IPageTransition
-    {
-        public event Action<bool> 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<Visual?, Visual?, bool>? 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();
         }
         }
     }
     }
 }
 }