From 69f728f1e89ad022372db4d6659f3a9ba1cc4a32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:34:37 +0000 Subject: [PATCH 01/12] feat: Implement AutoReverse support for Timeline animations Co-authored-by: MartinZikmund <1075116+MartinZikmund@users.noreply.github.com> --- .../Given_DoubleAnimation.cs | 138 ++++++++++++++++++ .../Timeline.cs | 24 +-- .../Media/Animation/Timeline.animation.cs | 23 ++- .../UI/Xaml/Media/Animation/Timeline.cs | 9 ++ 4 files changed, 171 insertions(+), 23 deletions(-) diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media_Animation/Given_DoubleAnimation.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media_Animation/Given_DoubleAnimation.cs index 54f4104be37e..f8301266f5a3 100644 --- a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media_Animation/Given_DoubleAnimation.cs +++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media_Animation/Given_DoubleAnimation.cs @@ -391,5 +391,143 @@ private static double GetTranslateY(TranslateTransform translate, bool isStillAn // And, when the animation is completed, this native value is reset even for HoldEnd animation. : translate.Y; #endif + + [TestMethod] + public async Task When_AutoReverse_True() + { + var target = new TextBlock() { Text = "Test AutoReverse" }; + WindowHelper.WindowContent = target; + await WindowHelper.WaitForIdle(); + await WindowHelper.WaitForLoaded(target); + + var transform = new TranslateTransform(); + target.RenderTransform = transform; + + var animation = new DoubleAnimation() + { + From = 0, + To = 100, + Duration = TimeSpan.FromMilliseconds(500), + AutoReverse = true, + FillBehavior = FillBehavior.HoldEnd, + }; + Storyboard.SetTarget(animation, transform); + Storyboard.SetTargetProperty(animation, nameof(TranslateTransform.X)); + + var storyboard = new Storyboard(); + storyboard.Children.Add(animation); + + bool completed = false; + storyboard.Completed += (s, e) => completed = true; + + storyboard.Begin(); + + // Wait for quarter of the animation (forward phase) + await Task.Delay(250); + var valueAt25Percent = transform.X; + + // Wait for just past halfway (should be near the end of forward phase) + await Task.Delay(300); + var valueAt55Percent = transform.X; + + // Wait for 75% of total duration (should be in reverse phase) + await Task.Delay(250); + var valueAt80Percent = transform.X; + + // Wait for completion + await WindowHelper.WaitFor(() => completed, timeoutMS: 2000); + + // Final value should be back at start (0) with HoldEnd + var finalValue = transform.X; + + // Verify animation went forward then backward + Assert.IsTrue(valueAt25Percent > 0 && valueAt25Percent < 100, + $"At 25%, value should be between 0 and 100, got {valueAt25Percent}"); + Assert.IsTrue(valueAt55Percent > valueAt25Percent, + $"At 55%, value should be greater than at 25%, got {valueAt55Percent} vs {valueAt25Percent}"); + Assert.IsTrue(valueAt80Percent < valueAt55Percent, + $"At 80% (reverse phase), value should be less than at 55%, got {valueAt80Percent} vs {valueAt55Percent}"); + Assert.IsTrue(Math.Abs(finalValue) < 10, + $"Final value should be close to 0, got {finalValue}"); + } + + [TestMethod] + public async Task When_AutoReverse_False() + { + var target = new TextBlock() { Text = "Test No AutoReverse" }; + WindowHelper.WindowContent = target; + await WindowHelper.WaitForIdle(); + await WindowHelper.WaitForLoaded(target); + + var transform = new TranslateTransform(); + target.RenderTransform = transform; + + var animation = new DoubleAnimation() + { + From = 0, + To = 100, + Duration = TimeSpan.FromMilliseconds(500), + AutoReverse = false, + FillBehavior = FillBehavior.HoldEnd, + }; + Storyboard.SetTarget(animation, transform); + Storyboard.SetTargetProperty(animation, nameof(TranslateTransform.X)); + + var storyboard = new Storyboard(); + storyboard.Children.Add(animation); + + bool completed = false; + storyboard.Completed += (s, e) => completed = true; + + storyboard.Begin(); + + // Wait for completion + await WindowHelper.WaitFor(() => completed, timeoutMS: 2000); + + // Final value should stay at end (100) with HoldEnd and no AutoReverse + var finalValue = transform.X; + Assert.IsTrue(Math.Abs(finalValue - 100) < 10, + $"Final value should be close to 100, got {finalValue}"); + } + + [TestMethod] + public async Task When_AutoReverse_WithRepeat() + { + var target = new TextBlock() { Text = "Test AutoReverse with Repeat" }; + WindowHelper.WindowContent = target; + await WindowHelper.WaitForIdle(); + await WindowHelper.WaitForLoaded(target); + + var transform = new TranslateTransform(); + target.RenderTransform = transform; + + var animation = new DoubleAnimation() + { + From = 0, + To = 50, + Duration = TimeSpan.FromMilliseconds(250), + AutoReverse = true, + RepeatBehavior = new RepeatBehavior(2), // Repeat twice (4 total half-cycles) + FillBehavior = FillBehavior.HoldEnd, + }; + Storyboard.SetTarget(animation, transform); + Storyboard.SetTargetProperty(animation, nameof(TranslateTransform.X)); + + var storyboard = new Storyboard(); + storyboard.Children.Add(animation); + + bool completed = false; + storyboard.Completed += (s, e) => completed = true; + + storyboard.Begin(); + + // Wait for completion (2 repeats * 2 phases * 250ms = 1000ms) + await WindowHelper.WaitFor(() => completed, timeoutMS: 2500); + + // Final value should be back at start (0) + var finalValue = transform.X; + Assert.IsTrue(Math.Abs(finalValue) < 10, + $"Final value should be close to 0 after repeating with AutoReverse, got {finalValue}"); + } } } diff --git a/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml.Media.Animation/Timeline.cs b/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml.Media.Animation/Timeline.cs index 9f8d3b94b0a0..2ac185d8b1df 100644 --- a/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml.Media.Animation/Timeline.cs +++ b/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml.Media.Animation/Timeline.cs @@ -26,20 +26,7 @@ public double SpeedRatio // Skipping already declared property FillBehavior // Skipping already declared property Duration // Skipping already declared property BeginTime -#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__ - [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")] - public bool AutoReverse - { - get - { - return (bool)this.GetValue(AutoReverseProperty); - } - set - { - this.SetValue(AutoReverseProperty, value); - } - } -#endif + // Skipping already declared property AutoReverse #if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__ [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")] public static bool AllowDependentAnimations @@ -54,14 +41,7 @@ public static bool AllowDependentAnimations } } #endif -#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__ - [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")] - public static global::Microsoft.UI.Xaml.DependencyProperty AutoReverseProperty { get; } = - Microsoft.UI.Xaml.DependencyProperty.Register( - nameof(AutoReverse), typeof(bool), - typeof(global::Microsoft.UI.Xaml.Media.Animation.Timeline), - new Microsoft.UI.Xaml.FrameworkPropertyMetadata(default(bool))); -#endif + // Skipping already declared property AutoReverseProperty // Skipping already declared property BeginTimeProperty // Skipping already declared property DurationProperty // Skipping already declared property FillBehaviorProperty diff --git a/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.animation.cs b/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.animation.cs index 4fff4c4a2eaa..71a03da9ac85 100644 --- a/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.animation.cs +++ b/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.animation.cs @@ -36,6 +36,7 @@ public static class TraceProvider private int _replayCount = 1; private T? _startingValue; private T? _endValue; + private bool _isReversing = false; // Track if we're in the reverse phase of AutoReverse // Initialize the field with zero capacity, as it may stay empty more often than it is being used. private readonly CompositeDisposable _subscriptions = new CompositeDisposable(0); @@ -61,6 +62,7 @@ private TimelineState State private TimeSpan? BeginTime => _owner?.BeginTime; private Duration Duration => _owner?.Duration ?? default(Duration); private FillBehavior FillBehavior => _owner?.FillBehavior ?? default(FillBehavior); + private bool AutoReverse => _owner?.AutoReverse ?? false; private T? From => AnimationOwner?.From; private T? To => AnimationOwner?.To; @@ -94,6 +96,7 @@ public void Begin() _activeDuration.Restart(); _replayCount = 1; + _isReversing = false; // Reset reversing state when beginning //Start the animation Play(); @@ -225,7 +228,11 @@ private void InitializeAnimator() _endValue = ComputeToValue(); - _animator = AnimatorFactory.Create(_owner, _startingValue.Value, _endValue.Value); + // If we're in the reverse phase, swap the from and to values + var fromValue = _isReversing ? _endValue.Value : _startingValue.Value; + var toValue = _isReversing ? _startingValue.Value : _endValue.Value; + + _animator = AnimatorFactory.Create(_owner, fromValue, toValue); _animator.SetEasingFunction(this.EasingFunction); //Set the Easing Function of the animator @@ -337,6 +344,20 @@ private void OnEnd() { _animator?.Dispose(); + // Handle AutoReverse: if enabled and we just finished the forward animation, reverse it + if (AutoReverse && !_isReversing) + { + _isReversing = true; + Replay(); // Replay in reverse + return; + } + + // If we were reversing, we've now completed both forward and reverse + if (_isReversing) + { + _isReversing = false; + } + // If the animation was GPU based, remove the animated value if (NeedsRepeat(_activeDuration, _replayCount)) diff --git a/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.cs b/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.cs index 4c83f608e48c..328fe9e1665c 100644 --- a/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.cs +++ b/src/Uno.UI/UI/Xaml/Media/Animation/Timeline.cs @@ -27,6 +27,15 @@ public event EventHandler Completed remove => _completedHandlers?.Remove(value); } + public bool AutoReverse + { + get => (bool)GetValue(AutoReverseProperty); + set => SetValue(AutoReverseProperty, value); + } + + public static DependencyProperty AutoReverseProperty { get; } = + DependencyProperty.Register("AutoReverse", typeof(bool), typeof(Timeline), new FrameworkPropertyMetadata(false)); + public Timeline() { IsAutoPropertyInheritanceEnabled = false; From 2c049aafc3483a8bfd5a3266a7a147bae552c459 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:38:14 +0000 Subject: [PATCH 02/12] feat: Add AutoReverse sample and fix SkipToFill behavior Co-authored-by: MartinZikmund <1075116+MartinZikmund@users.noreply.github.com> --- .../DoubleAnimation_AutoReverse.xaml | 65 +++++++++++++++++++ .../DoubleAnimation_AutoReverse.xaml.cs | 42 ++++++++++++ .../Media/Animation/Timeline.animation.cs | 3 +- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Media_Animation/DoubleAnimation_AutoReverse.xaml create mode 100644 src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Media_Animation/DoubleAnimation_AutoReverse.xaml.cs diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Media_Animation/DoubleAnimation_AutoReverse.xaml b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Media_Animation/DoubleAnimation_AutoReverse.xaml new file mode 100644 index 000000000000..98944fb8aefd --- /dev/null +++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Media_Animation/DoubleAnimation_AutoReverse.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + +