Skip to content

Commit

Permalink
Merge pull request #3929 from Sergio0694/feature/animation-builder-ca…
Browse files Browse the repository at this point in the history
…llback

Added AnimationBuilder.Start(UIElement, Action) overload
  • Loading branch information
michael-hawker authored Apr 21, 2021
2 parents 385e4fb + 2aae986 commit c31e049
Show file tree
Hide file tree
Showing 3 changed files with 279 additions and 0 deletions.
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

0 comments on commit c31e049

Please sign in to comment.