Browse Source

Merge branch 'master' into avalonia-ios-performance

pull/20969/head
Javier Suárez 16 hours ago
committed by GitHub
parent
commit
7483a08d6a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 60
      src/Avalonia.Base/Animation/Animation.cs
  2. 44
      src/Avalonia.Base/Animation/AnimationInstance`1.cs
  3. 9
      src/Avalonia.Base/Animation/Animators/Animator`1.cs
  4. 6
      src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs
  5. 9
      src/Avalonia.Base/Animation/Animators/TransformAnimator.cs
  6. 24
      src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs
  7. 3
      src/Avalonia.Base/Animation/IAnimation.cs
  8. 5
      src/Avalonia.Base/Animation/IAnimator.cs
  9. 29
      src/Avalonia.Base/Animation/PlaybackBehavior.cs
  10. 6
      src/Avalonia.Base/Media/Effects/EffectAnimator.cs
  11. 3
      src/Avalonia.Base/Styling/StyleInstance.cs
  12. 310
      tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs

60
src/Avalonia.Base/Animation/Animation.cs

@ -42,6 +42,15 @@ namespace Avalonia.Animation
o => o._playbackDirection,
(o, v) => o._playbackDirection = v);
/// <summary>
/// Defines the <see cref="PlaybackBehavior"/> property.
/// </summary>
public static readonly DirectProperty<Animation, PlaybackBehavior> PlaybackBehaviorProperty =
AvaloniaProperty.RegisterDirect<Animation, PlaybackBehavior>(
nameof(PlaybackBehavior),
o => o._playbackBehavior,
(o, v) => o._playbackBehavior = v);
/// <summary>
/// Defines the <see cref="FillMode"/> property.
/// </summary>
@ -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); }
}
/// <summary>
/// Gets or sets the playback behavior for this animation.
/// When set to <see cref="PlaybackBehavior.Auto"/>, manually started animations and
/// animations targeting <see cref="Visual.IsVisibleProperty"/> always play,
/// while style-applied animations pause when the control is not effectively visible
/// (see <see cref="Visual.IsEffectivelyVisible"/>).
/// </summary>
public PlaybackBehavior PlaybackBehavior
{
get { return _playbackBehavior; }
set { SetAndRaise(PlaybackBehaviorProperty, ref _playbackBehavior, value); }
}
/// <summary>
/// Gets or sets the value fill mode for this animation.
/// </summary>
@ -192,11 +215,12 @@ namespace Avalonia.Animation
return null;
}
private (IList<IAnimator> Animators, IList<IDisposable> subscriptions) InterpretKeyframes(Animatable control)
private (IList<IAnimator> Animators, IList<IDisposable> subscriptions, bool animatesVisibility) InterpretKeyframes(Animatable control)
{
var handlerList = new Dictionary<(Type type, AvaloniaProperty Property), Func<IAnimator>>();
var animatorKeyFrames = new List<AnimatorKeyFrame>();
var subscriptions = new List<IDisposable>();
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<bool> match, Action? onComplete)
=> Apply(control, clock, match, onComplete);
IDisposable IAnimation.Apply(Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete,
bool isManuallyStarted)
=> Apply(control, clock, match, onComplete, isManuallyStarted);
/// <inheritdoc cref="IAnimation.Apply"/>
internal IDisposable Apply(Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete)
internal IDisposable Apply(Animatable control, IClock? clock, IObservable<bool> 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(() =>
{

44
src/Avalonia.Base/Animation/AnimationInstance`1.cs

@ -38,7 +38,9 @@ namespace Avalonia.Animation
private EventHandler? _visibilityChangedHandler;
private EventHandler<VisualTreeAttachmentEventArgs>? _detachedHandler;
public AnimationInstance(Animation animation, Animatable control, Animator<T> animator, IClock baseClock, Action? OnComplete, Func<double, T, T> Interpolator)
private readonly bool _shouldPauseOnInvisible;
public AnimationInstance(Animation animation, Animatable control, Animator<T> animator, IClock baseClock, Action? OnComplete, Func<double, T, T> 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();

9
src/Avalonia.Base/Animation/Animators/Animator`1.cs

@ -17,9 +17,9 @@ namespace Avalonia.Animation.Animators
public AvaloniaProperty? Property { get; set; }
/// <inheritdoc/>
public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete)
public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete, bool shouldPauseOnInvisible)
{
var subject = new DisposeAnimationInstanceSubject<T>(this, animation, control, clock, onComplete);
var subject = new DisposeAnimationInstanceSubject<T>(this, animation, control, clock, onComplete, shouldPauseOnInvisible);
return new CompositeDisposable(match.Subscribe(subject), subject);
}
@ -103,7 +103,7 @@ namespace Avalonia.Animation.Animators
/// <summary>
/// Runs the KeyFrames Animation.
/// </summary>
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<T>(
animation,
@ -111,7 +111,8 @@ namespace Avalonia.Animation.Animators
this,
clock ?? control.Clock ?? Clock.GlobalClock,
onComplete,
InterpolationHandler);
InterpolationHandler,
shouldPauseOnInvisible);
return BindAnimation(control, instance);
}

6
src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs

@ -38,20 +38,20 @@ namespace Avalonia.Animation.Animators
/// <inheritdoc/>
public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
IObservable<bool> match, Action? onComplete)
IObservable<bool> 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);
}
/// <summary>

9
src/Avalonia.Base/Animation/Animators/TransformAnimator.cs

@ -14,7 +14,8 @@ namespace Avalonia.Animation.Animators
DoubleAnimator? _doubleAnimator;
/// <inheritdoc/>
public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable<bool> obsMatch, Action? onComplete)
public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
IObservable<bool> 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);
}
}
}

24
src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs

@ -6,24 +6,18 @@ namespace Avalonia.Animation
/// <summary>
/// Manages the lifetime of animation instances as determined by its selector state.
/// </summary>
internal class DisposeAnimationInstanceSubject<T> : IObserver<bool>, IDisposable
internal class DisposeAnimationInstanceSubject<T>(
Animator<T> animator,
Animation animation,
Animatable control,
IClock? clock,
Action? onComplete,
bool shouldPauseOnInvisible)
: IObserver<bool>, IDisposable
{
private IDisposable? _lastInstance;
private bool _lastMatch;
private readonly Animator<T> _animator;
private readonly Animation _animation;
private readonly Animatable _control;
private readonly Action? _onComplete;
private readonly IClock? _clock;
public DisposeAnimationInstanceSubject(Animator<T> 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
{

3
src/Avalonia.Base/Animation/IAnimation.cs

@ -14,7 +14,8 @@ namespace Avalonia.Animation
/// <summary>
/// Apply the animation to the specified control and run it when <paramref name="match" /> produces <c>true</c>.
/// </summary>
internal IDisposable Apply(Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete = null);
internal IDisposable Apply(Animatable control, IClock? clock, IObservable<bool> match,
Action? onComplete = null, bool isManuallyStarted = false);
/// <summary>
/// Run the animation on the specified control.

5
src/Avalonia.Base/Animation/IAnimator.cs

@ -12,11 +12,12 @@ namespace Avalonia.Animation
/// <summary>
/// The target property.
/// </summary>
AvaloniaProperty? Property {get; set;}
AvaloniaProperty? Property { get; set; }
/// <summary>
/// Applies the current KeyFrame group to the specified control.
/// </summary>
IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable<bool> match, Action? onComplete);
IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
IObservable<bool> match, Action? onComplete, bool shouldPauseOnInvisible);
}
}

29
src/Avalonia.Base/Animation/PlaybackBehavior.cs

@ -0,0 +1,29 @@
namespace Avalonia.Animation
{
/// <summary>
/// Determines whether an animation pauses when its target control is not effectively visible
/// (see <see cref="Visual.IsEffectivelyVisible"/>).
/// </summary>
public enum PlaybackBehavior
{
/// <summary>
/// The system decides based on context. Manually started animations
/// (via <see cref="Animation.RunAsync(Animatable, System.Threading.CancellationToken)"/>)
/// and animations that target <see cref="Visual.IsVisibleProperty"/> always play.
/// Style-applied animations pause when the control is not effectively visible
/// (see <see cref="Visual.IsEffectivelyVisible"/>).
/// </summary>
Auto,
/// <summary>
/// The animation always plays regardless of the control's effective visibility state.
/// </summary>
Always,
/// <summary>
/// The animation pauses when the control is not effectively visible
/// (see <see cref="Visual.IsEffectivelyVisible"/>).
/// </summary>
OnlyIfVisible,
}
}

6
src/Avalonia.Base/Media/Effects/EffectAnimator.cs

@ -10,17 +10,17 @@ namespace Avalonia.Animation.Animators;
internal class EffectAnimator : Animator<IEffect?>
{
public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
IObservable<bool> match, Action? onComplete)
IObservable<bool> match, Action? onComplete, bool shouldPauseOnInvisible)
{
if (TryCreateAnimator<BlurEffectAnimator, IBlurEffect>(out var animator)
|| TryCreateAnimator<DropShadowEffectAnimator, IDropShadowEffect>(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<TAnimator, TInterface>([NotNullWhen(true)] out IAnimator? animator)

3
src/Avalonia.Base/Styling/StyleInstance.cs

@ -72,7 +72,8 @@ namespace Avalonia.Styling
_animationTrigger ??= new LightweightSubject<bool>();
_animationApplyDisposables ??= new List<IDisposable>();
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);

310
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<double>
{
public double LastProgress { get; set; } = double.NaN;

Loading…
Cancel
Save