diff --git a/src/Avalonia.Animation/Animatable.cs b/src/Avalonia.Animation/Animatable.cs index 324ff06452..7d6df716b8 100644 --- a/src/Avalonia.Animation/Animatable.cs +++ b/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 /// public class Animatable : AvaloniaObject { + /// + /// Defines the property. + /// public static readonly StyledProperty ClockProperty = AvaloniaProperty.Register(nameof(Clock), inherits: true); - public IClock Clock - { - get => GetValue(ClockProperty); - set => SetValue(ClockProperty, value); - } - /// /// Defines the property. /// @@ -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? _transitionState; - private Dictionary _previousTransitions; + /// + /// Gets or sets the clock which controls the animations on the control. + /// + public IClock Clock + { + get => GetValue(ClockProperty); + set => SetValue(ClockProperty, value); + } /// /// 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(); + _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(); + if (_transitions is object) + { + RemoveTransitions(_transitions); + _transitions.CollectionChanged -= TransitionsCollectionChanged; + } SetAndRaise(TransitionsProperty, ref _transitions, value); + _transitions.CollectionChanged += TransitionsCollectionChanged; + AddTransitions(_transitions); + } + } + + /// + /// Enables transitions for the control. + /// + /// + /// 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. + /// + protected void EnableTransitions() + { + if (!_transitionsEnabled) + { + _transitionsEnabled = true; + + if (_transitions is object) + { + AddTransitions(_transitions); + } + } + } + + /// + /// Disables transitions for the control. + /// + /// + /// 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. + /// + protected void DisableTransitions() + { + if (_transitionsEnabled) + { + _transitionsEnabled = false; + + if (_transitions is object) + { + RemoveTransitions(_transitions); + } + } + } + + protected sealed override void OnPropertyChangedCore(AvaloniaPropertyChangedEventArgs 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(), + 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(AvaloniaPropertyChangedEventArgs 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(); + + 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; } + } } } diff --git a/src/Avalonia.Animation/Transitions.cs b/src/Avalonia.Animation/Transitions.cs index 2741039ebc..6687a2902d 100644 --- a/src/Avalonia.Animation/Transitions.cs +++ b/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."); + } } } } diff --git a/src/Avalonia.Visuals/Visual.cs b/src/Avalonia.Visuals/Visual.cs index 36b72fa28e..bb9a4cf208 100644 --- a/src/Avalonia.Visuals/Visual.cs +++ b/src/Avalonia.Visuals/Visual.cs @@ -114,6 +114,9 @@ namespace Avalonia /// public Visual() { + // Disable transitions until we're added to the visual tree. + DisableTransitions(); + var visualChildren = new AvaloniaList(); 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); diff --git a/tests/Avalonia.Animation.UnitTests/AnimatableTests.cs b/tests/Avalonia.Animation.UnitTests/AnimatableTests.cs new file mode 100644 index 0000000000..e1169650a9 --- /dev/null +++ b/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(), + 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()) + { + Setters = + { + new Setter(Visual.OpacityProperty, 0.8), + } + } + } + }; + + root.Child = control; + + Assert.Equal(0.8, control.Opacity); + + target.Verify(x => x.Apply( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + 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(), + 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(), + 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(), + 1.0, + 0.5)); + target.ResetCalls(); + + control.SetValue(Visual.OpacityProperty, 0.8, BindingPriority.StyleTrigger); + + target.Verify(x => x.Apply( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void Transition_Is_Disposed_When_Local_Value_Changes() + { + var target = CreateTarget(); + var control = CreateControl(target.Object); + var sub = new Mock(); + + target.Setup(x => x.Apply(control, It.IsAny(), 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(), 1.0, 0.5)) + .Callback(() => + { + control.SetValue(Visual.OpacityProperty, 0.9, BindingPriority.Animation); + }) + .Returns(Mock.Of()); + + control.Opacity = 0.5; + + Assert.Equal(0.9, control.Opacity); + target.ResetCalls(); + + control.Opacity = 0.4; + + target.Verify(x => x.Apply( + control, + It.IsAny(), + 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(), + 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(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void Animation_Is_Cancelled_When_Transition_Removed() + { + var target = CreateTarget(); + var control = CreateControl(target.Object); + var sub = new Mock(); + + target.Setup(x => x.Apply( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).Returns(sub.Object); + + control.Opacity = 0.5; + control.Transitions.RemoveAt(0); + + sub.Verify(x => x.Dispose()); + } + + private static Mock CreateTarget() + { + var target = new Mock(); + var sub = new Mock(); + + target.Setup(x => x.Property).Returns(Visual.OpacityProperty); + target.Setup(x => x.Apply( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).Returns(sub.Object); + + return target; + } + + private static Control CreateControl(ITransition transition) + { + var control = new Control + { + Transitions = { transition }, + }; + + var root = new TestRoot(control); + return control; + } + } +}