diff --git a/Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs b/Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs index c139e01a288..b5d4b02044d 100644 --- a/Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs +++ b/Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs @@ -4,8 +4,10 @@ #nullable enable +using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Windows.UI.Composition; @@ -98,6 +100,152 @@ public void Start(UIElement element) } } + /// + /// Starts the animations present in the current instance. + /// + /// The target to animate. + /// The callback to invoke when the animation completes. + public void Start(UIElement element, Action callback) + { + // The point of this overload is to allow consumers to invoke a callback when an animation + // completes, without having to create an async state machine. There are three different possible + // scenarios to handle, and each can have a specialized code path to ensure the implementation + // is as lean and efficient as possible. Specifically, for a given AnimationBuilder instance: + // 1) There are only Composition animations + // 2) There are only XAML animations + // 3) There are both Composition and XAML animations + // The implementation details of each of these paths is described below. + if (this.compositionAnimationFactories.Count > 0) + { + if (this.xamlAnimationFactories.Count == 0) + { + // There are only Composition animations. In this case we can just use a Composition scoped batch, + // capture the user-provided callback and invoke it directly when the batch completes. There is no + // additional overhead here, since we would've had to create a closure regardless to be able to monitor + // the completion of the animation (eg. to capture a TaskCompletionSource like we're doing below). + static void Start(AnimationBuilder builder, UIElement element, Action callback) + { + ElementCompositionPreview.SetIsTranslationEnabled(element, true); + + Visual visual = ElementCompositionPreview.GetElementVisual(element); + CompositionScopedBatch batch = visual.Compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + + batch.Completed += (_, _) => callback(); + + foreach (var factory in builder.compositionAnimationFactories) + { + var animation = factory.GetAnimation(visual, out var target); + + if (target is null) + { + visual.StartAnimation(animation.Target, animation); + } + else + { + target.StartAnimation(animation.Target, animation); + } + } + + batch.End(); + } + + Start(this, element, callback); + } + else + { + // In this case we need to wait for both the Composition and XAML animation groups to complete. These two + // groups use different APIs and can have a different duration, so we need to synchronize between them + // without creating an async state machine (as that'd defeat the point of this separate overload). + // + // The code below relies on a mutable boxed counter that's shared across the two closures for the Completed + // events for both the Composition scoped batch and the XAML Storyboard. The counter is initialized to 2, and + // when each group completes, the counter is decremented (we don't need an interlocked decrement as the delegates + // will already be invoked on the current DispatcherQueue instance, which acts as the synchronization context here. + // The handlers for the Composition batch and the Storyboard will never execute concurrently). If the counter has + // reached zero, it means that both groups have completed, so the user-provided callback is triggered, otherwise + // the handler just does nothing. This ensures that the callback is executed exactly once when all the animation + // complete, but without the need to create TaskCompletionSource-s and an async state machine to await for that. + // + // Note: we're using StrongBox here because that exposes a mutable field of the type we need (int). + // We can't just mutate a boxed int in-place with Unsafe.Unbox as that's against the ECMA spec, since + // that API uses the unbox IL opcode (§III.4.32) which returns a "controlled-mutability managed pointer" + // (§III.1.8.1.2.2), which is not "verifier-assignable-to" (ie. directly assigning to it is not legal). + static void Start(AnimationBuilder builder, UIElement element, Action callback) + { + StrongBox counter = new(2); + + ElementCompositionPreview.SetIsTranslationEnabled(element, true); + + Visual visual = ElementCompositionPreview.GetElementVisual(element); + CompositionScopedBatch batch = visual.Compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + + batch.Completed += (_, _) => + { + if (--counter.Value == 0) + { + callback(); + } + }; + + foreach (var factory in builder.compositionAnimationFactories) + { + var animation = factory.GetAnimation(visual, out var target); + + if (target is null) + { + visual.StartAnimation(animation.Target, animation); + } + else + { + target.StartAnimation(animation.Target, animation); + } + } + + batch.End(); + + Storyboard storyboard = new(); + + foreach (var factory in builder.xamlAnimationFactories) + { + storyboard.Children.Add(factory.GetAnimation(element)); + } + + storyboard.Completed += (_, _) => + { + if (--counter.Value == 0) + { + callback(); + } + }; + storyboard.Begin(); + } + + Start(this, element, callback); + } + } + else + { + // There are only XAML animations. This case is extremely similar to that where we only have Composition + // animations, with the main difference being that the Completed event is directly exposed from the + // Storyboard type, so we don't need a separate type to track the animation completion. The same + // considerations regarding the closure to capture the provided callback apply here as well. + static void Start(AnimationBuilder builder, UIElement element, Action callback) + { + Storyboard storyboard = new(); + + foreach (var factory in builder.xamlAnimationFactories) + { + storyboard.Children.Add(factory.GetAnimation(element)); + } + + storyboard.Completed += (_, _) => callback(); + storyboard.Begin(); + } + + Start(this, element, callback); + } + } + /// /// Starts the animations present in the current instance, and /// registers a given cancellation token to stop running animations before they complete. diff --git a/UnitTests/UnitTests.UWP/UI/Animations/Test_AnimationBuilderStart.cs b/UnitTests/UnitTests.UWP/UI/Animations/Test_AnimationBuilderStart.cs new file mode 100644 index 00000000000..47c18154ad5 --- /dev/null +++ b/UnitTests/UnitTests.UWP/UI/Animations/Test_AnimationBuilderStart.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Toolkit.Uwp; +using Windows.UI.Xaml.Controls; +using Microsoft.Toolkit.Uwp.UI.Animations; +using System.Numerics; +using Microsoft.Toolkit.Uwp.UI; +using System; +using Windows.UI.Xaml.Media; + +namespace UnitTests.UWP.UI.Animations +{ + [TestClass] + [TestCategory("Test_AnimationBuilderStart")] + public class Test_AnimationBuilderStart : VisualUITestBase + { + [TestMethod] + public async Task Start_WithCallback_CompositionOnly() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var button = new Button(); + var grid = new Grid() { Children = { button } }; + + await SetTestContentAsync(grid); + + var tcs = new TaskCompletionSource(); + + AnimationBuilder.Create() + .Scale( + to: new Vector3(1.2f, 1, 1), + delay: TimeSpan.FromMilliseconds(400)) + .Opacity( + to: 0.7, + duration: TimeSpan.FromSeconds(1)) + .Start(button, () => tcs.SetResult(null)); + + await tcs.Task; + + // Note: we're just testing Scale and Opacity here as they're among the Visual properties that + // are kept in sync on the Visual object after an animation completes, so we can use their + // values below to check that the animations have run correctly. There is no particular reason + // why we chose these two animations specifically other than this. For instance, checking + // Visual.TransformMatrix.Translation or Visual.Offset after an animation targeting those + // properties doesn't correctly report the final value and remains out of sync ¯\_(ツ)_/¯ + Assert.AreEqual(button.GetVisual().Scale, new Vector3(1.2f, 1, 1)); + Assert.AreEqual(button.GetVisual().Opacity, 0.7f); + }); + } + + [TestMethod] + public async Task Start_WithCallback_XamlOnly() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var button = new Button(); + var grid = new Grid() { Children = { button } }; + + await SetTestContentAsync(grid); + + var tcs = new TaskCompletionSource(); + + AnimationBuilder.Create() + .Translation( + to: new Vector2(80, 20), + layer: FrameworkLayer.Xaml) + .Scale( + to: new Vector2(1.2f, 1), + delay: TimeSpan.FromMilliseconds(400), + layer: FrameworkLayer.Xaml) + .Opacity( + to: 0.7, + duration: TimeSpan.FromSeconds(1), + layer: FrameworkLayer.Xaml) + .Start(button, () => tcs.SetResult(null)); + + await tcs.Task; + + CompositeTransform transform = button.RenderTransform as CompositeTransform; + + Assert.IsNotNull(transform); + Assert.AreEqual(transform.TranslateX, 80); + Assert.AreEqual(transform.TranslateY, 20); + Assert.AreEqual(transform.ScaleX, 1.2, 0.0000001); + Assert.AreEqual(transform.ScaleY, 1, 0.0000001); + Assert.AreEqual(button.Opacity, 0.7, 0.0000001); + }); + } + + [TestMethod] + public async Task Start_WithCallback_CompositionAndXaml() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var button = new Button(); + var grid = new Grid() { Children = { button } }; + + await SetTestContentAsync(grid); + + var tcs = new TaskCompletionSource(); + + AnimationBuilder.Create() + .Scale( + to: new Vector3(1.2f, 1, 1), + delay: TimeSpan.FromMilliseconds(400)) + .Opacity( + to: 0.7, + duration: TimeSpan.FromSeconds(1)) + .Translation( + to: new Vector2(80, 20), + layer: FrameworkLayer.Xaml) + .Start(button, () => tcs.SetResult(null)); + + await tcs.Task; + + CompositeTransform transform = button.RenderTransform as CompositeTransform; + + Assert.AreEqual(button.GetVisual().Scale, new Vector3(1.2f, 1, 1)); + Assert.AreEqual(button.GetVisual().Opacity, 0.7f); + Assert.IsNotNull(transform); + Assert.AreEqual(transform.TranslateX, 80); + Assert.AreEqual(transform.TranslateY, 20); + }); + } + } +} \ No newline at end of file diff --git a/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj b/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj index 0474048f95c..ff55da9ad64 100644 --- a/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj +++ b/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj @@ -196,6 +196,7 @@ +