浏览代码

Merge pull request #3854 from AvaloniaUI/fixes/3109-transitions

Reworked transitions.
Jumar Macato 5 年之前
父节点
当前提交
15caf2bcab

+ 184 - 31
src/Avalonia.Animation/Animatable.cs

@@ -1,10 +1,10 @@
 using System;
+using System.Collections;
 using System.Collections.Generic;
-using System.Linq;
-using System.Reactive.Linq;
-using Avalonia.Collections;
+using System.Collections.Specialized;
 using Avalonia.Data;
-using Avalonia.Animation.Animators;
+
+#nullable enable
 
 namespace Avalonia.Animation
 {
@@ -13,15 +13,12 @@ namespace Avalonia.Animation
     /// </summary>
     public class Animatable : AvaloniaObject
     {
+        /// <summary>
+        /// Defines the <see cref="Clock"/> property.
+        /// </summary>
         public static readonly StyledProperty<IClock> ClockProperty =
             AvaloniaProperty.Register<Animatable, IClock>(nameof(Clock), inherits: true);
 
-        public IClock Clock
-        {
-            get => GetValue(ClockProperty);
-            set => SetValue(ClockProperty, value);
-        }
-
         /// <summary>
         /// Defines the <see cref="Transitions"/> property.
         /// </summary>
@@ -31,9 +28,18 @@ namespace Avalonia.Animation
                 o => o.Transitions,
                 (o, v) => o.Transitions = v);
 
-        private Transitions _transitions;
+        private bool _transitionsEnabled = true;
+        private Transitions? _transitions;
+        private Dictionary<ITransition, TransitionState>? _transitionState;
 
-        private Dictionary<AvaloniaProperty, IDisposable> _previousTransitions;
+        /// <summary>
+        /// Gets or sets the clock which controls the animations on the control.
+        /// </summary>
+        public IClock Clock
+        {
+            get => GetValue(ClockProperty);
+            set => SetValue(ClockProperty, value);
+        }
 
         /// <summary>
         /// Gets or sets the property transitions for the control.
@@ -43,48 +49,195 @@ namespace Avalonia.Animation
             get
             {
                 if (_transitions is null)
+                {
                     _transitions = new Transitions();
-
-                if (_previousTransitions is null)
-                    _previousTransitions = new Dictionary<AvaloniaProperty, IDisposable>();
+                    _transitions.CollectionChanged += TransitionsCollectionChanged;
+                }
 
                 return _transitions;
             }
             set
             {
+                // TODO: This is a hack, Setter should not replace transitions, but should add/remove.
                 if (value is null)
+                {
                     return;
+                }
 
-                if (_previousTransitions is null)
-                    _previousTransitions = new Dictionary<AvaloniaProperty, IDisposable>();
+                if (_transitions is object)
+                {
+                    RemoveTransitions(_transitions);
+                    _transitions.CollectionChanged -= TransitionsCollectionChanged;
+                }
 
                 SetAndRaise(TransitionsProperty, ref _transitions, value);
+                _transitions.CollectionChanged += TransitionsCollectionChanged;
+                AddTransitions(_transitions);
+            }
+        }
+
+        /// <summary>
+        /// Enables transitions for the control.
+        /// </summary>
+        /// <remarks>
+        /// This method should not be called from user code, it will be called automatically by the framework
+        /// when a control is added to the visual tree.
+        /// </remarks>
+        protected void EnableTransitions()
+        {
+            if (!_transitionsEnabled)
+            {
+                _transitionsEnabled = true;
+
+                if (_transitions is object)
+                {
+                    AddTransitions(_transitions);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Disables transitions for the control.
+        /// </summary>
+        /// <remarks>
+        /// This method should not be called from user code, it will be called automatically by the framework
+        /// when a control is added to the visual tree.
+        /// </remarks>
+        protected void DisableTransitions()
+        {
+            if (_transitionsEnabled)
+            {
+                _transitionsEnabled = false;
+
+                if (_transitions is object)
+                {
+                    RemoveTransitions(_transitions);
+                }
+            }
+        }
+
+        protected sealed override void OnPropertyChangedCore<T>(AvaloniaPropertyChangedEventArgs<T> change)
+        {
+            if (_transitionsEnabled &&
+                _transitions is object &&
+                _transitionState is object &&
+                change.Priority > BindingPriority.Animation)
+            {
+                foreach (var transition in _transitions)
+                {
+                    if (transition.Property == change.Property)
+                    {
+                        var state = _transitionState[transition];
+                        var oldValue = state.BaseValue;
+                        var newValue = GetAnimationBaseValue(transition.Property);
+
+                        if (!Equals(oldValue, newValue))
+                        {
+                            state.BaseValue = newValue;
+
+                            // We need to transition from the current animated value if present,
+                            // instead of the old base value.
+                            var animatedValue = GetValue(transition.Property);
+
+                            if (!Equals(newValue, animatedValue))
+                            {
+                                oldValue = animatedValue;
+                            }
+
+                            state.Instance?.Dispose();
+                            state.Instance = transition.Apply(
+                                this,
+                                Clock ?? AvaloniaLocator.Current.GetService<IGlobalClock>(),
+                                oldValue,
+                                newValue);
+                            return;
+                        }
+                    }
+                }
+            }
+
+            base.OnPropertyChangedCore(change);
+        }
+
+        private void TransitionsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+        {
+            if (!_transitionsEnabled)
+            {
+                return;
+            }
+
+            switch (e.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                    AddTransitions(e.NewItems);
+                    break;
+                case NotifyCollectionChangedAction.Remove:
+                    RemoveTransitions(e.OldItems);
+                    break;
+                case NotifyCollectionChangedAction.Replace:
+                    RemoveTransitions(e.OldItems);
+                    AddTransitions(e.NewItems);
+                    break;
+                case NotifyCollectionChangedAction.Reset:
+                    throw new NotSupportedException("Transitions collection cannot be reset.");
             }
         }
 
-        protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change)
+        private void AddTransitions(IList items)
         {
-            if (_transitions is null || _previousTransitions is null || change.Priority == BindingPriority.Animation)
+            if (!_transitionsEnabled)
+            {
                 return;
+            }
 
-            // PERF-SENSITIVE: Called on every property change. Don't use LINQ here (too many allocations).
-            foreach (var transition in _transitions)
+            _transitionState ??= new Dictionary<ITransition, TransitionState>();
+
+            for (var i = 0; i < items.Count; ++i)
             {
-                if (transition.Property == change.Property)
+                var t = (ITransition)items[i];
+
+                _transitionState.Add(t, new TransitionState
                 {
-                    if (_previousTransitions.TryGetValue(change.Property, out var dispose))
-                        dispose.Dispose();
+                    BaseValue = GetAnimationBaseValue(t.Property),
+                });
+            }
+        }
 
-                    var instance = transition.Apply(
-                        this,
-                        Clock ?? Avalonia.Animation.Clock.GlobalClock,
-                        change.OldValue.GetValueOrDefault(),
-                        change.NewValue.GetValueOrDefault());
+        private void RemoveTransitions(IList items)
+        {
+            if (_transitionState is null)
+            {
+                return;
+            }
 
-                    _previousTransitions[change.Property] = instance;
-                    return;
+            for (var i = 0; i < items.Count; ++i)
+            {
+                var t = (ITransition)items[i];
+
+                if (_transitionState.TryGetValue(t, out var state))
+                {
+                    state.Instance?.Dispose();
+                    _transitionState.Remove(t);
                 }
             }
         }
+
+        private object GetAnimationBaseValue(AvaloniaProperty property)
+        {
+            var value = this.GetBaseValue(property, BindingPriority.LocalValue);
+
+            if (value == AvaloniaProperty.UnsetValue)
+            {
+                value = GetValue(property);
+            }
+
+            return value;
+        }
+
+        private class TransitionState
+        {
+            public IDisposable? Instance { get; set; }
+            public object? BaseValue { get; set; }
+        }
     }
 }

+ 13 - 0
src/Avalonia.Animation/Transitions.cs

@@ -1,4 +1,6 @@
+using System;
 using Avalonia.Collections;
+using Avalonia.Threading;
 
 namespace Avalonia.Animation
 {
@@ -13,6 +15,17 @@ namespace Avalonia.Animation
         public Transitions()
         {
             ResetBehavior = ResetBehavior.Remove;
+            Validate = ValidateTransition;
+        }
+
+        private void ValidateTransition(ITransition obj)
+        {
+            Dispatcher.UIThread.VerifyAccess();
+
+            if (obj.Property.IsDirect)
+            {
+                throw new InvalidOperationException("Cannot animate a direct property.");
+            }
         }
     }
 }

+ 5 - 0
src/Avalonia.Visuals/Visual.cs

@@ -114,6 +114,9 @@ namespace Avalonia
         /// </summary>
         public Visual()
         {
+            // Disable transitions until we're added to the visual tree.
+            DisableTransitions();
+
             var visualChildren = new AvaloniaList<IVisual>();
             visualChildren.ResetBehavior = ResetBehavior.Remove;
             visualChildren.Validate = visual => ValidateVisualChild(visual);
@@ -393,6 +396,7 @@ namespace Avalonia
                 RenderTransform.Changed += RenderTransformChanged;
             }
 
+            EnableTransitions();
             OnAttachedToVisualTree(e);
             AttachedToVisualTree?.Invoke(this, e);
             InvalidateVisual();
@@ -429,6 +433,7 @@ namespace Avalonia
                 RenderTransform.Changed -= RenderTransformChanged;
             }
 
+            DisableTransitions();
             OnDetachedFromVisualTree(e);
             DetachedFromVisualTree?.Invoke(this, e);
             e.Root?.Renderer?.AddDirty(this);

+ 242 - 0
tests/Avalonia.Animation.UnitTests/AnimatableTests.cs

@@ -0,0 +1,242 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Styling;
+using Avalonia.UnitTests;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Animation.UnitTests
+{
+    public class AnimatableTests
+    {
+        [Fact]
+        public void Transition_Is_Not_Applied_When_Not_Attached_To_Visual_Tree()
+        {
+            var target = CreateTarget();
+            var control = new Control
+            {
+                Transitions = { target.Object },
+            };
+
+            control.Opacity = 0.5;
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                1.0,
+                0.5),
+                Times.Never);
+        }
+
+        [Fact]
+        public void Transition_Is_Not_Applied_To_Initial_Style()
+        {
+            using (UnitTestApplication.Start(TestServices.RealStyler))
+            {
+                var target = CreateTarget();
+                var control = new Control
+                {
+                    Transitions = { target.Object },
+                };
+
+                var root = new TestRoot
+                {
+                    Styles =
+                    {
+                        new Style(x => x.OfType<Control>())
+                        {
+                            Setters =
+                            {
+                                new Setter(Visual.OpacityProperty, 0.8),
+                            }
+                        }
+                    }
+                };
+
+                root.Child = control;
+
+                Assert.Equal(0.8, control.Opacity);
+
+                target.Verify(x => x.Apply(
+                    It.IsAny<Control>(),
+                    It.IsAny<IClock>(),
+                    It.IsAny<object>(),
+                    It.IsAny<object>()),
+                    Times.Never);
+            }
+        }
+
+        [Fact]
+        public void Transition_Is_Applied_When_Local_Value_Changes()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+
+            control.Opacity = 0.5;
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                1.0,
+                0.5));
+        }
+
+        [Fact]
+        public void Transition_Is_Not_Applied_When_Animated_Value_Changes()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+
+            control.SetValue(Visual.OpacityProperty, 0.5, BindingPriority.Animation);
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                1.0,
+                0.5),
+                Times.Never);
+        }
+
+        [Fact]
+        public void Transition_Is_Not_Applied_When_StyleTrigger_Changes_With_LocalValue_Present()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+
+            control.SetValue(Visual.OpacityProperty, 0.5);
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                1.0,
+                0.5));
+            target.ResetCalls();
+
+            control.SetValue(Visual.OpacityProperty, 0.8, BindingPriority.StyleTrigger);
+
+            target.Verify(x => x.Apply(
+                It.IsAny<Control>(),
+                It.IsAny<IClock>(),
+                It.IsAny<object>(),
+                It.IsAny<object>()),
+                Times.Never);
+        }
+
+        [Fact]
+        public void Transition_Is_Disposed_When_Local_Value_Changes()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+            var sub = new Mock<IDisposable>();
+
+            target.Setup(x => x.Apply(control, It.IsAny<IClock>(), 1.0, 0.5)).Returns(sub.Object);
+
+            control.Opacity = 0.5;
+            sub.ResetCalls();
+            control.Opacity = 0.4;
+
+            sub.Verify(x => x.Dispose());
+        }
+
+        [Fact]
+        public void New_Transition_Is_Applied_When_Local_Value_Changes()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+
+            target.Setup(x => x.Property).Returns(Visual.OpacityProperty);
+            target.Setup(x => x.Apply(control, It.IsAny<IClock>(), 1.0, 0.5))
+                .Callback(() =>
+                {
+                    control.SetValue(Visual.OpacityProperty, 0.9, BindingPriority.Animation);
+                })
+                .Returns(Mock.Of<IDisposable>());
+
+            control.Opacity = 0.5;
+
+            Assert.Equal(0.9, control.Opacity);
+            target.ResetCalls();
+
+            control.Opacity = 0.4;
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                0.9,
+                0.4));
+        }
+
+        [Fact]
+        public void Transition_Is_Not_Applied_When_Removed_From_Visual_Tree()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+
+            control.Opacity = 0.5;
+
+            target.Verify(x => x.Apply(
+                control,
+                It.IsAny<IClock>(),
+                1.0,
+                0.5));
+            target.ResetCalls();
+
+            var root = (TestRoot)control.Parent;
+            root.Child = null;
+            control.Opacity = 0.8;
+
+            target.Verify(x => x.Apply(
+                It.IsAny<Control>(),
+                It.IsAny<IClock>(),
+                It.IsAny<object>(),
+                It.IsAny<object>()),
+                Times.Never);
+        }
+
+        [Fact]
+        public void Animation_Is_Cancelled_When_Transition_Removed()
+        {
+            var target = CreateTarget();
+            var control = CreateControl(target.Object);
+            var sub = new Mock<IDisposable>();
+
+            target.Setup(x => x.Apply(
+                It.IsAny<Animatable>(),
+                It.IsAny<IClock>(),
+                It.IsAny<object>(),
+                It.IsAny<object>())).Returns(sub.Object);
+
+            control.Opacity = 0.5;
+            control.Transitions.RemoveAt(0);
+
+            sub.Verify(x => x.Dispose());
+        }
+
+        private static Mock<ITransition> CreateTarget()
+        {
+            var target = new Mock<ITransition>();
+            var sub = new Mock<IDisposable>();
+
+            target.Setup(x => x.Property).Returns(Visual.OpacityProperty);
+            target.Setup(x => x.Apply(
+                It.IsAny<Animatable>(),
+                It.IsAny<IClock>(),
+                It.IsAny<object>(),
+                It.IsAny<object>())).Returns(sub.Object);
+
+            return target;
+        }
+
+        private static Control CreateControl(ITransition transition)
+        {
+            var control = new Control
+            {
+                Transitions = { transition },
+            };
+
+            var root = new TestRoot(control);
+            return control;
+        }
+    }
+}