Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,6 +100,152 @@ public void Start(UIElement element)
}
}

/// <summary>
/// Starts the animations present in the current <see cref="AnimationBuilder"/> instance.
/// </summary>
/// <param name="element">The target <see cref="UIElement"/> to animate.</param>
/// <param name="callback">The callback to invoke when the animation completes.</param>
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<T> 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<T> 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<int> 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);
}
}

/// <summary>
/// Starts the animations present in the current <see cref="AnimationBuilder"/> instance, and
/// registers a given cancellation token to stop running animations before they complete.
Expand Down
130 changes: 130 additions & 0 deletions UnitTests/UnitTests.UWP/UI/Animations/Test_AnimationBuilderStart.cs
Original file line number Diff line number Diff line change
@@ -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<object>();

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<object>();

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<object>();

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);
});
}
}
}
1 change: 1 addition & 0 deletions UnitTests/UnitTests.UWP/UnitTests.UWP.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
<Compile Include="PrivateType.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Helpers\Test_WeakEventListener.cs" />
<Compile Include="UI\Animations\Test_AnimationBuilderStart.cs" />
<Compile Include="UI\Controls\Test_Carousel.cs" />
<Compile Include="UI\Controls\Test_BladeView.cs" />
<Compile Include="UI\Controls\Test_RadialGauge.cs" />
Expand Down