diff --git a/.gitignore b/.gitignore index 826b4d8a5a..23b8efffa0 100644 --- a/.gitignore +++ b/.gitignore @@ -219,3 +219,4 @@ src/Browser/Avalonia.Browser.Blazor/wwwroot src/Browser/Avalonia.Browser/wwwroot api/diff src/Browser/Avalonia.Browser/staticwebassets +.serena diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index 3be646d66c..6358dd2c6b 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -7,7 +7,7 @@ using Avalonia.Data; namespace Avalonia.Animation { /// - /// Handles interpolation and time-related functions + /// Handles interpolation and time-related functions /// for keyframe animations. /// internal class AnimationInstance : SingleSubscriberObservableBase @@ -35,6 +35,8 @@ namespace Avalonia.Animation private readonly IClock _baseClock; private IClock? _clock; private EventHandler? _propertyChangedDelegate; + private EventHandler? _visibilityChangedHandler; + private EventHandler? _detachedHandler; public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func 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) { diff --git a/src/Avalonia.Base/Input/InputElement.Gestures.cs b/src/Avalonia.Base/Input/InputElement.Gestures.cs index 1ad1146282..83f350f0e7 100644 --- a/src/Avalonia.Base/Input/InputElement.Gestures.cs +++ b/src/Avalonia.Base/Input/InputElement.Gestures.cs @@ -149,7 +149,7 @@ namespace Avalonia.Input } /// - /// Occurs when a pinch gesture occurs on the control. + /// Occurs when the user moves two contact points closer together. /// public event EventHandler? Pinch { @@ -158,7 +158,7 @@ namespace Avalonia.Input } /// - /// Occurs when a pinch gesture ends on the control. + /// Occurs when the user releases both contact points used in a pinch gesture. /// public event EventHandler? PinchEnded { @@ -167,7 +167,7 @@ namespace Avalonia.Input } /// - /// Occurs when a pull gesture occurs on the control. + /// Occurs when the user drags from the edge of a control. /// public event EventHandler? PullGesture { @@ -176,7 +176,7 @@ namespace Avalonia.Input } /// - /// Occurs when a pull gesture ends on the control. + /// Occurs when the user releases the pointer after a pull gesture. /// public event EventHandler? PullGestureEnded { @@ -185,7 +185,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? ScrollGesture { @@ -194,7 +194,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? ScrollGestureInertiaStarting { @@ -203,7 +203,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? ScrollGestureEnded { @@ -212,7 +212,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? PointerTouchPadGestureMagnify { @@ -221,7 +221,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? PointerTouchPadGestureRotate { @@ -230,7 +230,7 @@ namespace Avalonia.Input } /// - /// Occurs when a swipe gesture occurs on the control. + /// Occurs when the user rapidly drags the pointer in a single direction across the control. /// public event EventHandler? SwipeGesture { @@ -239,7 +239,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? PointerTouchPadGestureSwipe { @@ -248,7 +248,7 @@ namespace Avalonia.Input } /// - /// Occurs when a tap gesture occurs on the control. + /// Occurs when the user briefly contacts and releases a single point, without significant movement. /// public event EventHandler? Tapped { @@ -257,7 +257,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? RightTapped { @@ -266,7 +266,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? Holding { @@ -275,7 +275,7 @@ namespace Avalonia.Input } /// - /// 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. /// public event EventHandler? DoubleTapped { diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs index fe6effbbd4..6f78ca312a 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs +++ b/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!); } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs index 4b98b0f80e..ed8860e04a 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs +++ b/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); } -} \ No newline at end of file +} diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs index fa8c6047fc..8352fc70e2 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs +++ b/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 diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 95d55754d3..4bd359fa78 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -208,6 +208,11 @@ namespace Avalonia /// public bool IsEffectivelyVisible { get; private set; } = true; + /// + /// Raised when changes. + /// + internal event EventHandler? IsEffectivelyVisibleChanged; + /// /// Updates the property based on the parent's /// . @@ -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. diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index febf465042..defe02769c 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -22,6 +22,7 @@ + diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index 4a3d67d0c7..752c1b166b 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/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()