Browse Source

Animation PlaybackBehavior Implementation and Tests.

pull/20966/head
Jumar Macato 20 hours ago
parent
commit
d1d1d3c41f
  1. 54
      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. 5
      src/Avalonia.Base/Animation/IAnimator.cs
  8. 26
      src/Avalonia.Base/Animation/PlaybackBehavior.cs
  9. 6
      src/Avalonia.Base/Media/Effects/EffectAnimator.cs
  10. 209
      tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs

54
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,18 @@ 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 visible.
/// </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 +214,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 +230,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 +291,30 @@ 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);
/// <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 +334,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 +386,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
{

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);
}
}

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

@ -0,0 +1,26 @@
namespace Avalonia.Animation
{
/// <summary>
/// Determines whether an animation pauses when its target control is not visible.
/// </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.
/// </summary>
Auto,
/// <summary>
/// The animation always plays regardless of the control's visibility state.
/// </summary>
Always,
/// <summary>
/// The animation pauses when the control is not effectively visible.
/// </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)

209
tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs

@ -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 }
};
@ -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 }
};
@ -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,206 @@ 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_Progresses_After_IsVisible_Set_True_On_Invisible_Control()
{
// Tests the expand scenario with a double property: the control starts invisible,
// and the animation drives Width from 0 to 100. With the pause-on-invisible
// behavior, the animation clock should still advance after the control becomes
// visible (even if made visible by the same logical expand action).
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(0.3),
Easing = new LinearEasing(),
FillMode = FillMode.Forward,
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);
clock.Step(TimeSpan.Zero);
// Simulate what the expand handler does: set IsVisible = true externally.
border.IsVisible = true;
// The animation should now be able to make progress.
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);
}
private sealed class FakeAnimator : InterpolatingAnimator<double>
{
public double LastProgress { get; set; } = double.NaN;

Loading…
Cancel
Save