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;