Browse Source

Merge branch 'master' into x11uintptr

pull/20864/head
Jumar Macato 2 weeks ago
committed by GitHub
parent
commit
aaf9880f4c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .gitignore
  2. 63
      src/Avalonia.Base/Animation/AnimationInstance`1.cs
  3. 30
      src/Avalonia.Base/Input/InputElement.Gestures.cs
  4. 6
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs
  5. 4
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs
  6. 7
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs
  7. 6
      src/Avalonia.Base/Visual.cs
  8. 1
      src/Avalonia.Base/composition-schema.xml
  9. 250
      tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs

1
.gitignore

@ -219,3 +219,4 @@ src/Browser/Avalonia.Browser.Blazor/wwwroot
src/Browser/Avalonia.Browser/wwwroot
api/diff
src/Browser/Avalonia.Browser/staticwebassets
.serena

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

@ -7,7 +7,7 @@ using Avalonia.Data;
namespace Avalonia.Animation
{
/// <summary>
/// Handles interpolation and time-related functions
/// Handles interpolation and time-related functions
/// for keyframe animations.
/// </summary>
internal class AnimationInstance<T> : SingleSubscriberObservableBase<T>
@ -35,6 +35,8 @@ namespace Avalonia.Animation
private readonly IClock _baseClock;
private IClock? _clock;
private EventHandler<AvaloniaPropertyChangedEventArgs>? _propertyChangedDelegate;
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)
{
@ -80,11 +82,34 @@ namespace Avalonia.Animation
protected override void Unsubscribed()
{
// Guard against reentrancy: DoComplete() can trigger Unsubscribed() via the
// _onCompleteAction disposal chain, and then PublishCompleted() calls it again.
var timerSub = _timerSub;
_timerSub = null;
if (timerSub is null)
return;
// Animation may have been stopped before it has finished.
ApplyFinalFill();
_targetControl.PropertyChanged -= _propertyChangedDelegate;
_timerSub?.Dispose();
timerSub.Dispose();
if (_targetControl is Visual visual)
{
if (_visibilityChangedHandler is not null)
{
visual.IsEffectivelyVisibleChanged -= _visibilityChangedHandler;
_visibilityChangedHandler = null;
}
if (_detachedHandler is not null)
{
visual.DetachedFromVisualTree -= _detachedHandler;
_detachedHandler = null;
}
}
_clock!.PlayState = PlayState.Stop;
}
@ -92,6 +117,35 @@ namespace Avalonia.Animation
{
_clock = new Clock(_baseClock);
_timerSub = _clock.Subscribe(Step);
if (_targetControl is Visual visual)
{
_visibilityChangedHandler = (_, _) =>
{
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();
visual.DetachedFromVisualTree += _detachedHandler;
}
_propertyChangedDelegate ??= ControlPropertyChanged;
_targetControl.PropertyChanged += _propertyChangedDelegate;
UpdateNeutralValue();
@ -101,7 +155,10 @@ namespace Avalonia.Animation
{
try
{
InternalStep(frameTick);
if (_clock?.PlayState == PlayState.Pause)
return;
InternalStep(frameTick);
}
catch (Exception e)
{

30
src/Avalonia.Base/Input/InputElement.Gestures.cs

@ -149,7 +149,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a pinch gesture occurs on the control.
/// Occurs when the user moves two contact points closer together.
/// </summary>
public event EventHandler<PinchEventArgs>? Pinch
{
@ -158,7 +158,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a pinch gesture ends on the control.
/// Occurs when the user releases both contact points used in a pinch gesture.
/// </summary>
public event EventHandler<PinchEndedEventArgs>? PinchEnded
{
@ -167,7 +167,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a pull gesture occurs on the control.
/// Occurs when the user drags from the edge of a control.
/// </summary>
public event EventHandler<PullGestureEventArgs>? PullGesture
{
@ -176,7 +176,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a pull gesture ends on the control.
/// Occurs when the user releases the pointer after a pull gesture.
/// </summary>
public event EventHandler<PullGestureEndedEventArgs>? PullGestureEnded
{
@ -185,7 +185,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a scroll gesture occurs on the control.
/// Occurs when the user continuously moves the pointer in the same direction within the control’s boundaries.
/// </summary>
public event EventHandler<ScrollGestureEventArgs>? ScrollGesture
{
@ -194,7 +194,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a scroll gesture inertia starts on the control.
/// Occurs within a scroll gesture, when the user releases the pointer, and scrolling continues by transitioning to momentum-based gliding movement.
/// </summary>
public event EventHandler<ScrollGestureInertiaStartingEventArgs>? ScrollGestureInertiaStarting
{
@ -203,7 +203,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a scroll gesture ends on the control.
/// Occurs when a scroll gesture has fully stopped, taking into account any inertial movement that continues the scroll after the user has released the pointer.
/// </summary>
public event EventHandler<ScrollGestureEndedEventArgs>? ScrollGestureEnded
{
@ -212,7 +212,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a touchpad magnify gesture occurs on the control.
/// Occurs when the user moves two contact points away from each other on a touchpad.
/// </summary>
public event EventHandler<PointerDeltaEventArgs>? PointerTouchPadGestureMagnify
{
@ -221,7 +221,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a touchpad rotate gesture occurs on the control.
/// Occurs when the user places two contact points and moves them in a circular motion on a touchpad.
/// </summary>
public event EventHandler<PointerDeltaEventArgs>? PointerTouchPadGestureRotate
{
@ -230,7 +230,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a swipe gesture occurs on the control.
/// Occurs when the user rapidly drags the pointer in a single direction across the control.
/// </summary>
public event EventHandler<SwipeGestureEventArgs>? SwipeGesture
{
@ -239,7 +239,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a touchpad swipe gesture occurs on the control.
/// Occurs when the user performs a rapid dragging motion in a single direction on a touchpad.
/// </summary>
public event EventHandler<PointerDeltaEventArgs>? PointerTouchPadGestureSwipe
{
@ -248,7 +248,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a tap gesture occurs on the control.
/// Occurs when the user briefly contacts and releases a single point, without significant movement.
/// </summary>
public event EventHandler<TappedEventArgs>? Tapped
{
@ -257,7 +257,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a right tap gesture occurs on the control.
/// Occurs when the user briefly contacts and releases a single point, without significant movement, using a mechanism on the input device recognized as a right button or equivalent.
/// </summary>
public event EventHandler<TappedEventArgs>? RightTapped
{
@ -266,7 +266,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a hold gesture occurs on the control.
/// Occurs when the user makes a single contact, then maintains contact beyond a given time threshold without releasing or making another contact.
/// </summary>
public event EventHandler<HoldingRoutedEventArgs>? Holding
{
@ -275,7 +275,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Occurs when a double-tap gesture occurs on the control.
/// Occurs when the user briefly contacts and releases twice on a single point, without significant movement.
/// </summary>
public event EventHandler<TappedEventArgs>? DoubleTapped
{

6
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs

@ -52,7 +52,7 @@ partial class ServerCompositionVisual
// We ignore Visual's RenderTransform completely since it's set by AdornerLayer and can be out of sync
// with compositor-driver animations
var ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, Matrix.Identity, Scale,
RotationAngle, Orientation, Offset);
RotationAngle, Orientation, Offset + Translation);
if (
AdornerLayer_GetExpectedSharedAncestor(this) is {} sharedAncestor
&& ComputeTransformFromAncestor(AdornedVisual, sharedAncestor, out var adornerLayerToAdornedVisual))
@ -63,7 +63,7 @@ partial class ServerCompositionVisual
}
else
_ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale,
RotationAngle, Orientation, Offset);
RotationAngle, Orientation, Offset + Translation);
PropagateFlags(true, true);
@ -170,4 +170,4 @@ partial class ServerCompositionVisual
_pools.IntStackPool.Return(ref _adornerPushedClipStack!);
}
}
}
}

4
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs

@ -148,7 +148,7 @@ partial class ServerCompositionVisual
if (_combinedTransformDirty)
{
_ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale,
RotationAngle, Orientation, Offset);
RotationAngle, Orientation, Offset + Translation);
setDirtyForRender = setDirtyBounds = true;
@ -161,4 +161,4 @@ partial class ServerCompositionVisual
_ownBoundsDirty = _clipSizeDirty = _combinedTransformDirty = _compositionFieldsDirty = false;
PropagateFlags(setDirtyBounds, setDirtyForRender, setHasExtraDirtyRect);
}
}
}

7
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs

@ -43,7 +43,9 @@ partial class ServerCompositionVisual
| CompositionVisualChangedFields.Orientation
| CompositionVisualChangedFields.OrientationAnimated
| CompositionVisualChangedFields.Offset
| CompositionVisualChangedFields.OffsetAnimated;
| CompositionVisualChangedFields.OffsetAnimated
| CompositionVisualChangedFields.Translation
| CompositionVisualChangedFields.TranslationAnimated;
private const CompositionVisualChangedFields ClipSizeDirtyMask =
CompositionVisualChangedFields.Size
@ -100,7 +102,8 @@ partial class ServerCompositionVisual
|| property == s_IdOfScaleProperty
|| property == s_IdOfRotationAngleProperty
|| property == s_IdOfOrientationProperty
|| property == s_IdOfOffsetProperty)
|| property == s_IdOfOffsetProperty
|| property == s_IdOfTranslationProperty)
TriggerCombinedTransformDirty();
if (property == s_IdOfClipToBoundsProperty

6
src/Avalonia.Base/Visual.cs

@ -208,6 +208,11 @@ namespace Avalonia
/// </summary>
public bool IsEffectivelyVisible { get; private set; } = true;
/// <summary>
/// Raised when <see cref="IsEffectivelyVisible"/> changes.
/// </summary>
internal event EventHandler? IsEffectivelyVisibleChanged;
/// <summary>
/// Updates the <see cref="IsEffectivelyVisible"/> property based on the parent's
/// <see cref="IsEffectivelyVisible"/>.
@ -221,6 +226,7 @@ namespace Avalonia
return;
IsEffectivelyVisible = isEffectivelyVisible;
IsEffectivelyVisibleChanged?.Invoke(this, EventArgs.Empty);
// PERF-SENSITIVE: This is called on entire hierarchy and using foreach or LINQ
// will cause extra allocations and overhead.

1
src/Avalonia.Base/composition-schema.xml

@ -22,6 +22,7 @@
<Property Name="Clip" Type="Avalonia.Platform.IGeometryImpl?" Internal="true" />
<Property Name="ClipToBounds" Type="bool" Animated="true" DefaultValue="true"/>
<Property Name="Offset" Type="Vector3D" Animated="true"/>
<Property Name="Translation" Type="Vector3D" Animated="true"/>
<Property Name="Size" Type="Vector" Animated="true"/>
<Property Name="AnchorPoint" Type="Vector" Animated="true"/>
<Property Name="CenterPoint" Type="Vector3D" Animated="true"/>

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

@ -93,6 +93,256 @@ namespace Avalonia.Base.UnitTests.Animation
Assert.True(animationRun.Status == TaskStatus.RanToCompletion);
Assert.Equal(border.Width, 100d);
}
[Fact]
public void Pause_Animation_When_IsEffectivelyVisible_Is_False()
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 150d), }, Cue = new Cue(0.5d)
};
var keyframe3 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d)
};
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(3),
Delay = TimeSpan.FromSeconds(3),
DelayBetweenIterations = TimeSpan.FromSeconds(3),
IterationCount = new IterationCount(2),
Children = { keyframe1, keyframe2, keyframe3 }
};
var border = new Border() { Height = 100d, Width = 100d };
var clock = new TestClock();
var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken);
border.Measure(Size.Infinity);
border.Arrange(new Rect(border.DesiredSize));
clock.Step(TimeSpan.Zero);
clock.Step(TimeSpan.FromSeconds(0));
Assert.Equal(100d, border.Width);
// Hide the border — this should pause the animation clock.
border.IsVisible = false;
clock.Step(TimeSpan.FromSeconds(4.5));
// Width should not change while invisible (animation is paused).
Assert.Equal(100d, border.Width);
// Show the border — animation resumes from where it left off.
border.IsVisible = true;
// The pause absorbed 4.5s of wall-clock time, so internal time = wall - 4.5.
// To reach internal time 6s (end of iteration 1, cue 1.0 -> width 200):
// wall = 4.5 + 6 = 10.5
clock.Step(TimeSpan.FromSeconds(10.5));
Assert.Equal(200d, border.Width);
// To complete the animation (internal time 14s triggers trailing delay of iter 2):
// wall = 4.5 + 14 = 18.5
clock.Step(TimeSpan.FromSeconds(18.5));
Assert.True(animationRun.Status == TaskStatus.RanToCompletion);
Assert.Equal(100d, border.Width);
}
[Fact]
public void Pause_Animation_When_IsEffectivelyVisible_Is_False_Nested()
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 150d), }, Cue = new Cue(0.5d)
};
var keyframe3 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d)
};
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(3),
Delay = TimeSpan.FromSeconds(3),
DelayBetweenIterations = TimeSpan.FromSeconds(3),
IterationCount = new IterationCount(2),
Children = { keyframe1, keyframe2, keyframe3 }
};
var border = new Border() { Height = 100d, Width = 100d };
var borderParent = new Border { Child = border };
var clock = new TestClock();
var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken);
border.Measure(Size.Infinity);
border.Arrange(new Rect(border.DesiredSize));
clock.Step(TimeSpan.Zero);
clock.Step(TimeSpan.FromSeconds(0));
Assert.Equal(100d, border.Width);
// Hide the parent — this makes border.IsEffectivelyVisible false,
// which should pause the animation clock.
borderParent.IsVisible = false;
clock.Step(TimeSpan.FromSeconds(4.5));
// Width should not change while parent is invisible (animation is paused).
Assert.Equal(100d, border.Width);
// Show the parent — animation resumes from where it left off.
borderParent.IsVisible = true;
// The pause absorbed 4.5s of wall-clock time, so internal time = wall - 4.5.
// To reach internal time 6s (end of iteration 1, cue 1.0 -> width 200):
// wall = 4.5 + 6 = 10.5
clock.Step(TimeSpan.FromSeconds(10.5));
Assert.Equal(200d, border.Width);
// To complete the animation (internal time 14s triggers trailing delay of iter 2):
// wall = 4.5 + 14 = 18.5
clock.Step(TimeSpan.FromSeconds(18.5));
Assert.True(animationRun.Status == TaskStatus.RanToCompletion);
Assert.Equal(100d, border.Width);
}
[Fact]
public void Stop_And_Dispose_Animation_When_Detached_From_Visual_Tree()
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d)
};
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(10),
IterationCount = new IterationCount(1),
Children = { keyframe2, keyframe1 }
};
var border = new Border() { Height = 100d, Width = 50d };
var root = new TestRoot(border);
var clock = new TestClock();
var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken);
clock.Step(TimeSpan.Zero);
clock.Step(TimeSpan.FromSeconds(0));
Assert.False(animationRun.IsCompleted);
// Detach from visual tree
root.Child = null;
// Animation should be completed/disposed
Assert.True(animationRun.IsCompleted);
// Further clock ticks should not affect the border
var widthAfterDetach = border.Width;
clock.Step(TimeSpan.FromSeconds(5));
clock.Step(TimeSpan.FromSeconds(10));
Assert.Equal(widthAfterDetach, border.Width);
}
[Fact]
public void Pause_Animation_When_Control_Starts_Invisible()
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d)
};
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(3),
IterationCount = new IterationCount(1),
Children = { keyframe2, keyframe1 }
};
var border = new Border() { Height = 100d, Width = 100d, IsVisible = false };
var clock = new TestClock();
var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken);
// Clock ticks while invisible should not advance the animation.
clock.Step(TimeSpan.Zero);
clock.Step(TimeSpan.FromSeconds(1));
clock.Step(TimeSpan.FromSeconds(2));
Assert.Equal(100d, border.Width);
Assert.False(animationRun.IsCompleted);
// Make visible — animation starts from the beginning.
border.IsVisible = true;
// The pause absorbed 2s of wall-clock time, so to reach internal time 3s:
// wall = 2 + 3 = 5
clock.Step(TimeSpan.FromSeconds(5));
Assert.True(animationRun.IsCompleted);
Assert.Equal(100d, border.Width);
}
[Fact]
public void Animation_Plays_Correctly_After_Reattach()
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d)
};
var animation = new Animation()
{
Duration = TimeSpan.FromSeconds(5),
IterationCount = new IterationCount(1),
FillMode = FillMode.Forward,
Children = { keyframe2, keyframe1 }
};
var border = new Border() { Height = 100d, Width = 50d };
var root = new TestRoot(border);
var clock = new TestClock();
var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken);
clock.Step(TimeSpan.Zero);
Assert.False(animationRun.IsCompleted);
// Detach — animation completes.
root.Child = null;
Assert.True(animationRun.IsCompleted);
// Reattach and start a fresh animation.
root.Child = border;
var clock2 = new TestClock();
var animationRun2 = animation.RunAsync(border, clock2, TestContext.Current.CancellationToken);
clock2.Step(TimeSpan.Zero);
Assert.False(animationRun2.IsCompleted);
clock2.Step(TimeSpan.FromSeconds(5));
Assert.True(animationRun2.IsCompleted);
Assert.Equal(200d, border.Width);
}
[Fact]
public void Check_FillModes_Start_and_End_Values_if_Retained()

Loading…
Cancel
Save