diff --git a/src/Avalonia.Base/Animation/Animation.cs b/src/Avalonia.Base/Animation/Animation.cs index 0391280ede..5f15f14534 100644 --- a/src/Avalonia.Base/Animation/Animation.cs +++ b/src/Avalonia.Base/Animation/Animation.cs @@ -42,6 +42,15 @@ namespace Avalonia.Animation o => o._playbackDirection, (o, v) => o._playbackDirection = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty PlaybackBehaviorProperty = + AvaloniaProperty.RegisterDirect( + nameof(PlaybackBehavior), + o => o._playbackBehavior, + (o, v) => o._playbackBehavior = v); + /// /// Defines the property. /// @@ -91,6 +100,7 @@ namespace Avalonia.Animation private TimeSpan _duration; private IterationCount _iterationCount = new IterationCount(1); private PlaybackDirection _playbackDirection; + private PlaybackBehavior _playbackBehavior; private FillMode _fillMode; private Easing _easing = new LinearEasing(); private TimeSpan _delay = TimeSpan.Zero; @@ -124,6 +134,19 @@ namespace Avalonia.Animation set { SetAndRaise(PlaybackDirectionProperty, ref _playbackDirection, value); } } + /// + /// Gets or sets the playback behavior for this animation. + /// When set to , manually started animations and + /// animations targeting always play, + /// while style-applied animations pause when the control is not effectively visible + /// (see ). + /// + public PlaybackBehavior PlaybackBehavior + { + get { return _playbackBehavior; } + set { SetAndRaise(PlaybackBehaviorProperty, ref _playbackBehavior, value); } + } + /// /// Gets or sets the value fill mode for this animation. /// @@ -192,11 +215,12 @@ namespace Avalonia.Animation return null; } - private (IList Animators, IList subscriptions) InterpretKeyframes(Animatable control) + private (IList Animators, IList subscriptions, bool animatesVisibility) InterpretKeyframes(Animatable control) { var handlerList = new Dictionary<(Type type, AvaloniaProperty Property), Func>(); var animatorKeyFrames = new List(); var subscriptions = new List(); + var animatesVisibility = false; foreach (var keyframe in Children) { @@ -207,6 +231,9 @@ namespace Avalonia.Animation throw new InvalidOperationException("No Setter property assigned."); } + if (setter.Property == Visual.IsVisibleProperty) + animatesVisibility = true; + var handler = Animation.GetAnimator(setter) ?? GetAnimatorType(setter.Property); if (handler == null) @@ -265,19 +292,31 @@ namespace Avalonia.Animation } } - return (newAnimatorInstances, subscriptions); + return (newAnimatorInstances, subscriptions, animatesVisibility); } - IDisposable IAnimation.Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete) - => Apply(control, clock, match, onComplete); - + IDisposable IAnimation.Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete, + bool isManuallyStarted) + => Apply(control, clock, match, onComplete, isManuallyStarted); + /// - internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete) + internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete, + bool isManuallyStarted = false) { - var (animators, subscriptions) = InterpretKeyframes(control); + var (animators, subscriptions, animatesVisibility) = InterpretKeyframes(control); + + var shouldPauseOnInvisible = _playbackBehavior switch + { + PlaybackBehavior.Auto => !(animatesVisibility || isManuallyStarted), + PlaybackBehavior.Always => false, + PlaybackBehavior.OnlyIfVisible => true, + _ => throw new InvalidOperationException($"Unknown PlaybackBehavior value: {_playbackBehavior}"), + }; + if (animators.Count == 1) { - var subscription = animators[0].Apply(this, control, clock, match, onComplete); + var subscription = animators[0].Apply(this, control, clock, match, + onComplete, shouldPauseOnInvisible); if (subscription is not null) { @@ -297,7 +336,8 @@ namespace Avalonia.Animation completionTasks!.Add(tcs.Task); } - var subscription = animator.Apply(this, control, clock, match, animatorOnComplete); + var subscription = animator.Apply(this, control, clock, match, + animatorOnComplete, shouldPauseOnInvisible); if (subscription is not null) { @@ -348,7 +388,7 @@ namespace Avalonia.Animation run.TrySetResult(null); subscriptions?.Dispose(); cancellation?.Dispose(); - }); + }, isManuallyStarted: true); cancellation = cancellationToken.Register(() => { diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index 6358dd2c6b..390a4a10b4 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -38,7 +38,9 @@ namespace Avalonia.Animation private EventHandler? _visibilityChangedHandler; private EventHandler? _detachedHandler; - public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator) + private readonly bool _shouldPauseOnInvisible; + + public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator, bool shouldPauseOnInvisible) { _animator = animator; _animation = animation; @@ -49,6 +51,7 @@ namespace Avalonia.Animation _lastInterpValue = default!; _firstKFValue = default!; _neutralValue = default!; + _shouldPauseOnInvisible = shouldPauseOnInvisible; FetchProperties(); } @@ -120,26 +123,29 @@ namespace Avalonia.Animation if (_targetControl is Visual visual) { - _visibilityChangedHandler = (_, _) => + if (_shouldPauseOnInvisible) { - if (_clock is null || _clock.PlayState == PlayState.Stop) - return; - if (visual.IsEffectivelyVisible) - { - if (_clock.PlayState == PlayState.Pause) - _clock.PlayState = PlayState.Run; - } - else + _visibilityChangedHandler = (_, _) => { - if (_clock.PlayState == PlayState.Run) - _clock.PlayState = PlayState.Pause; - } - }; - visual.IsEffectivelyVisibleChanged += _visibilityChangedHandler; - - // If already invisible when animation starts, pause immediately. - if (!visual.IsEffectivelyVisible) - _clock.PlayState = PlayState.Pause; + if (_clock is null || _clock.PlayState == PlayState.Stop) + return; + if (visual.IsEffectivelyVisible) + { + if (_clock.PlayState == PlayState.Pause) + _clock.PlayState = PlayState.Run; + } + else + { + if (_clock.PlayState == PlayState.Run) + _clock.PlayState = PlayState.Pause; + } + }; + visual.IsEffectivelyVisibleChanged += _visibilityChangedHandler; + + // If already invisible when animation starts, pause immediately. + if (!visual.IsEffectivelyVisible) + _clock.PlayState = PlayState.Pause; + } // Stop and dispose the animation when detached from the visual tree. _detachedHandler = (_, _) => DoComplete(); diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs index 954b62f9bc..20311c8389 100644 --- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs @@ -17,9 +17,9 @@ namespace Avalonia.Animation.Animators public AvaloniaProperty? Property { get; set; } /// - public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete) + public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { - var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete); + var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete, shouldPauseOnInvisible); return new CompositeDisposable(match.Subscribe(subject), subject); } @@ -103,7 +103,7 @@ namespace Avalonia.Animation.Animators /// /// Runs the KeyFrames Animation. /// - internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete) + internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete, bool shouldPauseOnInvisible) { var instance = new AnimationInstance( animation, @@ -111,7 +111,8 @@ namespace Avalonia.Animation.Animators this, clock ?? control.Clock ?? Clock.GlobalClock, onComplete, - InterpolationHandler); + InterpolationHandler, + shouldPauseOnInvisible); return BindAnimation(control, instance); } diff --git a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs index c81be67060..e18b98ad26 100644 --- a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs @@ -38,20 +38,20 @@ namespace Avalonia.Animation.Animators /// public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, - IObservable match, Action? onComplete) + IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { if (TryCreateCustomRegisteredAnimator(out var animator) || TryCreateGradientAnimator(out animator) || TryCreateSolidColorBrushAnimator(out animator)) { - return animator.Apply(animation, control, clock, match, onComplete); + return animator.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( this, "The animation's keyframe value types set is not supported."); - return base.Apply(animation, control, clock, match, onComplete); + return base.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } /// diff --git a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs index b1fc720e6a..aef5c78f95 100644 --- a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs @@ -14,7 +14,8 @@ namespace Avalonia.Animation.Animators DoubleAnimator? _doubleAnimator; /// - public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable obsMatch, Action? onComplete) + public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable obsMatch, Action? onComplete, bool shouldPauseOnInvisible) { var ctrl = (Visual)control; @@ -65,7 +66,8 @@ namespace Avalonia.Animation.Animators // It's a transform object so let's target that. if (renderTransformType == Property.OwnerType) { - return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete); + return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, + obsMatch, onComplete, shouldPauseOnInvisible); } // It's a TransformGroup and try finding the target there. else if (renderTransformType == typeof(TransformGroup)) @@ -74,7 +76,8 @@ namespace Avalonia.Animation.Animators { if (transform.GetType() == Property.OwnerType) { - return _doubleAnimator.Apply(animation, transform, clock ?? control.Clock, obsMatch, onComplete); + return _doubleAnimator.Apply(animation, transform, clock ?? control.Clock, + obsMatch, onComplete, shouldPauseOnInvisible); } } } diff --git a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs index af25766289..7d355cc77c 100644 --- a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs +++ b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs @@ -6,24 +6,18 @@ namespace Avalonia.Animation /// /// Manages the lifetime of animation instances as determined by its selector state. /// - internal class DisposeAnimationInstanceSubject : IObserver, IDisposable + internal class DisposeAnimationInstanceSubject( + Animator animator, + Animation animation, + Animatable control, + IClock? clock, + Action? onComplete, + bool shouldPauseOnInvisible) + : IObserver, IDisposable { private IDisposable? _lastInstance; private bool _lastMatch; - private readonly Animator _animator; - private readonly Animation _animation; - private readonly Animatable _control; - private readonly Action? _onComplete; - private readonly IClock? _clock; - public DisposeAnimationInstanceSubject(Animator animator, Animation animation, Animatable control, IClock? clock, Action? onComplete) - { - this._animator = animator; - this._animation = animation; - this._control = control; - this._onComplete = onComplete; - this._clock = clock; - } public void Dispose() { _lastInstance?.Dispose(); @@ -47,7 +41,7 @@ namespace Avalonia.Animation if (matchVal) { - _lastInstance = _animator.Run(_animation, _control, _clock, _onComplete); + _lastInstance = animator.Run(animation, control, clock, onComplete, shouldPauseOnInvisible); } else { diff --git a/src/Avalonia.Base/Animation/IAnimation.cs b/src/Avalonia.Base/Animation/IAnimation.cs index a5b8b75b12..53146a3edd 100644 --- a/src/Avalonia.Base/Animation/IAnimation.cs +++ b/src/Avalonia.Base/Animation/IAnimation.cs @@ -14,7 +14,8 @@ namespace Avalonia.Animation /// /// Apply the animation to the specified control and run it when produces true. /// - internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete = null); + internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, + Action? onComplete = null, bool isManuallyStarted = false); /// /// Run the animation on the specified control. diff --git a/src/Avalonia.Base/Animation/IAnimator.cs b/src/Avalonia.Base/Animation/IAnimator.cs index 9fed7be9be..f065ad3730 100644 --- a/src/Avalonia.Base/Animation/IAnimator.cs +++ b/src/Avalonia.Base/Animation/IAnimator.cs @@ -12,11 +12,12 @@ namespace Avalonia.Animation /// /// The target property. /// - AvaloniaProperty? Property {get; set;} + AvaloniaProperty? Property { get; set; } /// /// Applies the current KeyFrame group to the specified control. /// - IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete); + IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable match, Action? onComplete, bool shouldPauseOnInvisible); } } diff --git a/src/Avalonia.Base/Animation/PlaybackBehavior.cs b/src/Avalonia.Base/Animation/PlaybackBehavior.cs new file mode 100644 index 0000000000..4dcfd5c784 --- /dev/null +++ b/src/Avalonia.Base/Animation/PlaybackBehavior.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Animation +{ + /// + /// Determines whether an animation pauses when its target control is not effectively visible + /// (see ). + /// + public enum PlaybackBehavior + { + /// + /// The system decides based on context. Manually started animations + /// (via ) + /// and animations that target always play. + /// Style-applied animations pause when the control is not effectively visible + /// (see ). + /// + Auto, + + /// + /// The animation always plays regardless of the control's effective visibility state. + /// + Always, + + /// + /// The animation pauses when the control is not effectively visible + /// (see ). + /// + OnlyIfVisible, + } +} diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs index e2c4cc096c..a81e58f2d0 100644 --- a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs +++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs @@ -10,17 +10,17 @@ namespace Avalonia.Animation.Animators; internal class EffectAnimator : Animator { public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, - IObservable match, Action? onComplete) + IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { if (TryCreateAnimator(out var animator) || TryCreateAnimator(out animator)) - return animator.Apply(animation, control, clock, match, onComplete); + return animator.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( this, "The animation's keyframe value types set is not supported."); - return base.Apply(animation, control, clock, match, onComplete); + return base.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } private bool TryCreateAnimator([NotNullWhen(true)] out IAnimator? animator) diff --git a/src/Avalonia.Base/Styling/StyleInstance.cs b/src/Avalonia.Base/Styling/StyleInstance.cs index c397aef8c6..1ecaf3734a 100644 --- a/src/Avalonia.Base/Styling/StyleInstance.cs +++ b/src/Avalonia.Base/Styling/StyleInstance.cs @@ -72,7 +72,8 @@ namespace Avalonia.Styling _animationTrigger ??= new LightweightSubject(); _animationApplyDisposables ??= new List(); foreach (var animation in _animations) - _animationApplyDisposables.Add(animation.Apply(animatable, null, _animationTrigger)); + _animationApplyDisposables.Add(animation.Apply(animatable, null, _animationTrigger, + onComplete: null, isManuallyStarted: false)); if (_activator is null) _animationTrigger.OnNext(true); diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index 752c1b166b..0ca5a3be6a 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs @@ -95,7 +95,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_IsEffectivelyVisible_Is_False() + public void OnlyIfVisible_Pauses_Animation_When_IsEffectivelyVisible_Is_False() { var keyframe1 = new KeyFrame() { @@ -117,6 +117,9 @@ namespace Avalonia.Base.UnitTests.Animation Delay = TimeSpan.FromSeconds(3), DelayBetweenIterations = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(2), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe1, keyframe2, keyframe3 } }; @@ -158,7 +161,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_IsEffectivelyVisible_Is_False_Nested() + public void OnlyIfVisible_Pauses_Animation_When_IsEffectivelyVisible_Is_False_Nested() { var keyframe1 = new KeyFrame() { @@ -180,6 +183,9 @@ namespace Avalonia.Base.UnitTests.Animation Delay = TimeSpan.FromSeconds(3), DelayBetweenIterations = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(2), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe1, keyframe2, keyframe3 } }; @@ -262,7 +268,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_Control_Starts_Invisible() + public void OnlyIfVisible_Pauses_Animation_When_Control_Starts_Invisible() { var keyframe1 = new KeyFrame() { @@ -276,6 +282,9 @@ namespace Avalonia.Base.UnitTests.Animation { Duration = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(1), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe2, keyframe1 } }; @@ -960,6 +969,301 @@ namespace Avalonia.Base.UnitTests.Animation } } + [Fact] + public void Animation_Can_Set_IsVisible_True_On_Invisible_Control() + { + // Reproduces a bug where an expand animation tries to make a collapsed + // (invisible) control visible at Cue 0.0, but the animation system pauses + // animations on invisible controls, creating a deadlock where the animation + // can't run to set IsVisible=true because the control is already invisible. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + } + } + }; + + // Control starts invisible (collapsed state). + var border = new Border() { IsVisible = false }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + // Kick off the animation. + clock.Step(TimeSpan.Zero); + + // The Cue 0.0 keyframe should have set IsVisible = true, + // even though the control started invisible. + Assert.True(border.IsVisible); + + // Animation should progress to completion. + clock.Step(TimeSpan.FromSeconds(0.3)); + Assert.True(animationRun.IsCompleted); + } + + [Fact] + public void Width_Animation_Resumes_After_IsVisible_Set_True_On_Invisible_Control() + { + // Tests the expand scenario with OnlyIfVisible: the control starts invisible + // and the animation is paused. Once IsVisible is set to true externally, + // the animation resumes and completes. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + Easing = new LinearEasing(), + FillMode = FillMode.Forward, + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Layoutable.WidthProperty, 0d) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Layoutable.WidthProperty, 100d) } + } + } + }; + + // Control starts invisible (collapsed state). + var border = new Border() { Width = 0d, IsVisible = false }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + // Animation is paused because control is invisible. + clock.Step(TimeSpan.Zero); + Assert.Equal(0d, border.Width); + Assert.False(animationRun.IsCompleted); + + // Simulate what the expand handler does: set IsVisible = true externally. + border.IsVisible = true; + + // The animation should now resume and complete. + clock.Step(TimeSpan.FromSeconds(0.3)); + Assert.True(animationRun.IsCompleted); + Assert.Equal(100d, border.Width); + } + + [Fact] + public void Animation_Can_Set_IsVisible_False_At_End_Without_Pausing_Itself() + { + // An animation that sets IsVisible=false at Cue 1.0 should complete normally. + // The visibility change at the final keyframe should not cause the animation + // to pause before it can report completion. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, false) } + } + } + }; + + // Control starts visible (expanded state). + var border = new Border() { IsVisible = true }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.True(border.IsVisible); + + // Step to the end: animation sets IsVisible=false. + clock.Step(TimeSpan.FromSeconds(0.3)); + + // Animation should have completed and the final value should hold. + Assert.True(animationRun.IsCompleted); + Assert.False(border.IsVisible); + } + + [Fact] + public async Task Cancelling_Expand_Animation_Mid_Flight_Then_Collapsing_Works() + { + // Reproduces the scenario where a user rapidly toggles expand/collapse: + // the first animation is cancelled and a new one starts in the opposite direction. + // Uses single-property animations to isolate the visibility behavior. + var expandAnimation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + } + } + }; + + var collapseAnimation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, false) } + } + } + }; + + var border = new Border() { IsVisible = false }; + + // Start expand. + var cts1 = new CancellationTokenSource(); + var clock1 = new TestClock(); + var expandRun = expandAnimation.RunAsync(border, clock1, cts1.Token); + + clock1.Step(TimeSpan.Zero); + Assert.True(border.IsVisible); + + // Partially through expand, cancel and start collapse. + clock1.Step(TimeSpan.FromSeconds(0.15)); + cts1.Cancel(); + await expandRun; + + var cts2 = new CancellationTokenSource(); + var clock2 = new TestClock(); + var collapseRun = collapseAnimation.RunAsync(border, clock2, cts2.Token); + + clock2.Step(TimeSpan.Zero); + clock2.Step(TimeSpan.FromSeconds(0.3)); + + Assert.True(collapseRun.IsCompleted); + Assert.False(border.IsVisible); + } + + [Fact] + public void Auto_Pauses_On_Invisible_When_Started_From_Style() + { + // When started via Apply (the style path), Auto resolves to OnlyIfVisible. + // The animation should pause when the control becomes invisible. + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d) }, Cue = new Cue(0d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d) }, Cue = new Cue(1d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(1), + Children = { keyframe1, keyframe2 } + }; + + var border = new Border() { Height = 100d, Width = 50d }; + + var clock = new TestClock(); + var completed = false; + + // Apply (not RunAsync), this is the style-applied path. + var disposable = animation.Apply(border, clock, Observable.Return(true), () => completed = true); + + clock.Step(TimeSpan.Zero); + Assert.Equal(100d, border.Width); + + // Hide the control, animation should pause under Auto. + border.IsVisible = false; + + clock.Step(TimeSpan.FromSeconds(1.5)); + // Width should not have advanced while invisible. + Assert.Equal(100d, border.Width); + + // Show the control, animation resumes. + border.IsVisible = true; + + clock.Step(TimeSpan.FromSeconds(4.5)); + Assert.True(completed); + + disposable.Dispose(); + } + + [Fact] + public void Auto_Does_Not_Pause_On_Invisible_When_Started_Manually() + { + // When started via RunAsync (manual), Auto resolves to Always. + // The animation should NOT pause when the control becomes invisible. + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d) }, Cue = new Cue(0d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d) }, Cue = new Cue(1d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(1), + Easing = new LinearEasing(), + FillMode = FillMode.Forward, + Children = { keyframe1, keyframe2 } + }; + + var border = new Border() { Height = 100d, Width = 50d }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.Equal(100d, border.Width); + + // Hide the control, animation should keep running under Auto + manual. + border.IsVisible = false; + + // Width should advance while invisible (not paused). + clock.Step(TimeSpan.FromSeconds(1.5)); + Assert.Equal(150d, border.Width); + Assert.False(animationRun.IsCompleted); + + clock.Step(TimeSpan.FromSeconds(3)); + Assert.True(animationRun.IsCompleted); + Assert.Equal(200d, border.Width); + } + private sealed class FakeAnimator : InterpolatingAnimator { public double LastProgress { get; set; } = double.NaN;