Skip to content

Commit c31e049

Browse files
Merge pull request #3929 from Sergio0694/feature/animation-builder-callback
Added AnimationBuilder.Start(UIElement, Action) overload
2 parents 385e4fb + 2aae986 commit c31e049

File tree

3 files changed

+279
-0
lines changed

3 files changed

+279
-0
lines changed

Microsoft.Toolkit.Uwp.UI.Animations/Builders/AnimationBuilder.cs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
#nullable enable
66

7+
using System;
78
using System.Collections.Generic;
89
using System.Diagnostics.Contracts;
10+
using System.Runtime.CompilerServices;
911
using System.Threading;
1012
using System.Threading.Tasks;
1113
using Windows.UI.Composition;
@@ -98,6 +100,152 @@ public void Start(UIElement element)
98100
}
99101
}
100102

103+
/// <summary>
104+
/// Starts the animations present in the current <see cref="AnimationBuilder"/> instance.
105+
/// </summary>
106+
/// <param name="element">The target <see cref="UIElement"/> to animate.</param>
107+
/// <param name="callback">The callback to invoke when the animation completes.</param>
108+
public void Start(UIElement element, Action callback)
109+
{
110+
// The point of this overload is to allow consumers to invoke a callback when an animation
111+
// completes, without having to create an async state machine. There are three different possible
112+
// scenarios to handle, and each can have a specialized code path to ensure the implementation
113+
// is as lean and efficient as possible. Specifically, for a given AnimationBuilder instance:
114+
// 1) There are only Composition animations
115+
// 2) There are only XAML animations
116+
// 3) There are both Composition and XAML animations
117+
// The implementation details of each of these paths is described below.
118+
if (this.compositionAnimationFactories.Count > 0)
119+
{
120+
if (this.xamlAnimationFactories.Count == 0)
121+
{
122+
// There are only Composition animations. In this case we can just use a Composition scoped batch,
123+
// capture the user-provided callback and invoke it directly when the batch completes. There is no
124+
// additional overhead here, since we would've had to create a closure regardless to be able to monitor
125+
// the completion of the animation (eg. to capture a TaskCompletionSource like we're doing below).
126+
static void Start(AnimationBuilder builder, UIElement element, Action callback)
127+
{
128+
ElementCompositionPreview.SetIsTranslationEnabled(element, true);
129+
130+
Visual visual = ElementCompositionPreview.GetElementVisual(element);
131+
CompositionScopedBatch batch = visual.Compositor.CreateScopedBatch(CompositionBatchTypes.Animation);
132+
133+
batch.Completed += (_, _) => callback();
134+
135+
foreach (var factory in builder.compositionAnimationFactories)
136+
{
137+
var animation = factory.GetAnimation(visual, out var target);
138+
139+
if (target is null)
140+
{
141+
visual.StartAnimation(animation.Target, animation);
142+
}
143+
else
144+
{
145+
target.StartAnimation(animation.Target, animation);
146+
}
147+
}
148+
149+
batch.End();
150+
}
151+
152+
Start(this, element, callback);
153+
}
154+
else
155+
{
156+
// In this case we need to wait for both the Composition and XAML animation groups to complete. These two
157+
// groups use different APIs and can have a different duration, so we need to synchronize between them
158+
// without creating an async state machine (as that'd defeat the point of this separate overload).
159+
//
160+
// The code below relies on a mutable boxed counter that's shared across the two closures for the Completed
161+
// events for both the Composition scoped batch and the XAML Storyboard. The counter is initialized to 2, and
162+
// when each group completes, the counter is decremented (we don't need an interlocked decrement as the delegates
163+
// will already be invoked on the current DispatcherQueue instance, which acts as the synchronization context here.
164+
// The handlers for the Composition batch and the Storyboard will never execute concurrently). If the counter has
165+
// reached zero, it means that both groups have completed, so the user-provided callback is triggered, otherwise
166+
// the handler just does nothing. This ensures that the callback is executed exactly once when all the animation
167+
// complete, but without the need to create TaskCompletionSource-s and an async state machine to await for that.
168+
//
169+
// Note: we're using StrongBox<T> here because that exposes a mutable field of the type we need (int).
170+
// We can't just mutate a boxed int in-place with Unsafe.Unbox<T> as that's against the ECMA spec, since
171+
// that API uses the unbox IL opcode (§III.4.32) which returns a "controlled-mutability managed pointer"
172+
// (§III.1.8.1.2.2), which is not "verifier-assignable-to" (ie. directly assigning to it is not legal).
173+
static void Start(AnimationBuilder builder, UIElement element, Action callback)
174+
{
175+
StrongBox<int> counter = new(2);
176+
177+
ElementCompositionPreview.SetIsTranslationEnabled(element, true);
178+
179+
Visual visual = ElementCompositionPreview.GetElementVisual(element);
180+
CompositionScopedBatch batch = visual.Compositor.CreateScopedBatch(CompositionBatchTypes.Animation);
181+
182+
batch.Completed += (_, _) =>
183+
{
184+
if (--counter.Value == 0)
185+
{
186+
callback();
187+
}
188+
};
189+
190+
foreach (var factory in builder.compositionAnimationFactories)
191+
{
192+
var animation = factory.GetAnimation(visual, out var target);
193+
194+
if (target is null)
195+
{
196+
visual.StartAnimation(animation.Target, animation);
197+
}
198+
else
199+
{
200+
target.StartAnimation(animation.Target, animation);
201+
}
202+
}
203+
204+
batch.End();
205+
206+
Storyboard storyboard = new();
207+
208+
foreach (var factory in builder.xamlAnimationFactories)
209+
{
210+
storyboard.Children.Add(factory.GetAnimation(element));
211+
}
212+
213+
storyboard.Completed += (_, _) =>
214+
{
215+
if (--counter.Value == 0)
216+
{
217+
callback();
218+
}
219+
};
220+
storyboard.Begin();
221+
}
222+
223+
Start(this, element, callback);
224+
}
225+
}
226+
else
227+
{
228+
// There are only XAML animations. This case is extremely similar to that where we only have Composition
229+
// animations, with the main difference being that the Completed event is directly exposed from the
230+
// Storyboard type, so we don't need a separate type to track the animation completion. The same
231+
// considerations regarding the closure to capture the provided callback apply here as well.
232+
static void Start(AnimationBuilder builder, UIElement element, Action callback)
233+
{
234+
Storyboard storyboard = new();
235+
236+
foreach (var factory in builder.xamlAnimationFactories)
237+
{
238+
storyboard.Children.Add(factory.GetAnimation(element));
239+
}
240+
241+
storyboard.Completed += (_, _) => callback();
242+
storyboard.Begin();
243+
}
244+
245+
Start(this, element, callback);
246+
}
247+
}
248+
101249
/// <summary>
102250
/// Starts the animations present in the current <see cref="AnimationBuilder"/> instance, and
103251
/// registers a given cancellation token to stop running animations before they complete.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Threading.Tasks;
6+
using Microsoft.VisualStudio.TestTools.UnitTesting;
7+
using Microsoft.Toolkit.Uwp;
8+
using Windows.UI.Xaml.Controls;
9+
using Microsoft.Toolkit.Uwp.UI.Animations;
10+
using System.Numerics;
11+
using Microsoft.Toolkit.Uwp.UI;
12+
using System;
13+
using Windows.UI.Xaml.Media;
14+
15+
namespace UnitTests.UWP.UI.Animations
16+
{
17+
[TestClass]
18+
[TestCategory("Test_AnimationBuilderStart")]
19+
public class Test_AnimationBuilderStart : VisualUITestBase
20+
{
21+
[TestMethod]
22+
public async Task Start_WithCallback_CompositionOnly()
23+
{
24+
await App.DispatcherQueue.EnqueueAsync(async () =>
25+
{
26+
var button = new Button();
27+
var grid = new Grid() { Children = { button } };
28+
29+
await SetTestContentAsync(grid);
30+
31+
var tcs = new TaskCompletionSource<object>();
32+
33+
AnimationBuilder.Create()
34+
.Scale(
35+
to: new Vector3(1.2f, 1, 1),
36+
delay: TimeSpan.FromMilliseconds(400))
37+
.Opacity(
38+
to: 0.7,
39+
duration: TimeSpan.FromSeconds(1))
40+
.Start(button, () => tcs.SetResult(null));
41+
42+
await tcs.Task;
43+
44+
// Note: we're just testing Scale and Opacity here as they're among the Visual properties that
45+
// are kept in sync on the Visual object after an animation completes, so we can use their
46+
// values below to check that the animations have run correctly. There is no particular reason
47+
// why we chose these two animations specifically other than this. For instance, checking
48+
// Visual.TransformMatrix.Translation or Visual.Offset after an animation targeting those
49+
// properties doesn't correctly report the final value and remains out of sync ¯\_(ツ)_/¯
50+
Assert.AreEqual(button.GetVisual().Scale, new Vector3(1.2f, 1, 1));
51+
Assert.AreEqual(button.GetVisual().Opacity, 0.7f);
52+
});
53+
}
54+
55+
[TestMethod]
56+
public async Task Start_WithCallback_XamlOnly()
57+
{
58+
await App.DispatcherQueue.EnqueueAsync(async () =>
59+
{
60+
var button = new Button();
61+
var grid = new Grid() { Children = { button } };
62+
63+
await SetTestContentAsync(grid);
64+
65+
var tcs = new TaskCompletionSource<object>();
66+
67+
AnimationBuilder.Create()
68+
.Translation(
69+
to: new Vector2(80, 20),
70+
layer: FrameworkLayer.Xaml)
71+
.Scale(
72+
to: new Vector2(1.2f, 1),
73+
delay: TimeSpan.FromMilliseconds(400),
74+
layer: FrameworkLayer.Xaml)
75+
.Opacity(
76+
to: 0.7,
77+
duration: TimeSpan.FromSeconds(1),
78+
layer: FrameworkLayer.Xaml)
79+
.Start(button, () => tcs.SetResult(null));
80+
81+
await tcs.Task;
82+
83+
CompositeTransform transform = button.RenderTransform as CompositeTransform;
84+
85+
Assert.IsNotNull(transform);
86+
Assert.AreEqual(transform.TranslateX, 80);
87+
Assert.AreEqual(transform.TranslateY, 20);
88+
Assert.AreEqual(transform.ScaleX, 1.2, 0.0000001);
89+
Assert.AreEqual(transform.ScaleY, 1, 0.0000001);
90+
Assert.AreEqual(button.Opacity, 0.7, 0.0000001);
91+
});
92+
}
93+
94+
[TestMethod]
95+
public async Task Start_WithCallback_CompositionAndXaml()
96+
{
97+
await App.DispatcherQueue.EnqueueAsync(async () =>
98+
{
99+
var button = new Button();
100+
var grid = new Grid() { Children = { button } };
101+
102+
await SetTestContentAsync(grid);
103+
104+
var tcs = new TaskCompletionSource<object>();
105+
106+
AnimationBuilder.Create()
107+
.Scale(
108+
to: new Vector3(1.2f, 1, 1),
109+
delay: TimeSpan.FromMilliseconds(400))
110+
.Opacity(
111+
to: 0.7,
112+
duration: TimeSpan.FromSeconds(1))
113+
.Translation(
114+
to: new Vector2(80, 20),
115+
layer: FrameworkLayer.Xaml)
116+
.Start(button, () => tcs.SetResult(null));
117+
118+
await tcs.Task;
119+
120+
CompositeTransform transform = button.RenderTransform as CompositeTransform;
121+
122+
Assert.AreEqual(button.GetVisual().Scale, new Vector3(1.2f, 1, 1));
123+
Assert.AreEqual(button.GetVisual().Opacity, 0.7f);
124+
Assert.IsNotNull(transform);
125+
Assert.AreEqual(transform.TranslateX, 80);
126+
Assert.AreEqual(transform.TranslateY, 20);
127+
});
128+
}
129+
}
130+
}

UnitTests/UnitTests.UWP/UnitTests.UWP.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
<Compile Include="PrivateType.cs" />
197197
<Compile Include="Properties\AssemblyInfo.cs" />
198198
<Compile Include="Helpers\Test_WeakEventListener.cs" />
199+
<Compile Include="UI\Animations\Test_AnimationBuilderStart.cs" />
199200
<Compile Include="UI\Controls\Test_Carousel.cs" />
200201
<Compile Include="UI\Controls\Test_BladeView.cs" />
201202
<Compile Include="UI\Controls\Test_RadialGauge.cs" />

0 commit comments

Comments
 (0)