I like this
I played a little with animations in the Visual Layer back on 10586 in this post and Rob has a better introduction here on his site about animations.
But the //Build 2016 session below talks about the idea of implicit animations;
which I’d summarise as a way for the system to monitor a property change on a Visual such that when that property changes it can step in to animate the change in the way that the developer has prescribed.
In pseudo-code;
var visual = GetSomeVisualFromSomewhere();
visual.Property = SomeValue;
with the idea being that on the second line of code the system will say “Aha, the Property value is changing – should I animate that for you?”.
I wanted to give this a quick trial and so I wrote some code which produces the effect in the short video snippet below;
and this is running on build 14352 with SDK 14332 and it’s just a XAML Grid containing a sub-grid and 2 buttons;
<Page x:Class="App4.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:App4" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <Grid x:Name="parentGrid"> </Grid> <StackPanel Grid.Row="1" HorizontalAlignment="Center" Orientation="Horizontal"> <Button Content="Expand" Click="OnExpand" /> <Button Content="Collapse" Click="OnCollapse" /> </StackPanel> </Grid> </Page>
and then a little bit of code behind;
using System; using System.Collections.Generic; using Windows.UI; using Windows.UI.Composition; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Hosting; using Windows.UI.Xaml.Media; using Windows.UI.Xaml.Shapes; namespace App4 { public sealed partial class MainPage : Page { public MainPage() { this.InitializeComponent(); this.Loaded += OnLoaded; } void OnLoaded(object sender, RoutedEventArgs e) { this.compositor = ElementCompositionPreview.GetElementVisual(this.parentGrid).Compositor; var brush = new SolidColorBrush(Colors.Red); for (int i = 0; i < NUM_CIRCLES; i++) { this.parentGrid.Children.Add( new Ellipse() { Width = RADIUS, Height = RADIUS, Fill = brush, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } ); } this.Initialise(); } void Initialise() { if (!initialised) { var animation = compositor.CreateVector3KeyFrameAnimation(); var easingFunction = compositor.CreateCubicBezierEasingFunction( new System.Numerics.Vector2(0.5f, 0.0f), new System.Numerics.Vector2(1.0f, 1.0f)); animation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", easingFunction); animation.Duration = TimeSpan.FromMilliseconds(250); animation.Target = "Offset"; var collection = compositor.CreateImplicitAnimationCollection(); collection["Offset"] = animation; this.visuals = new List<Visual>(); foreach (var child in this.parentGrid.Children) { var ellipse = child as Ellipse; var visual = ElementCompositionPreview.GetElementVisual(ellipse); visual.ImplicitAnimations = collection; this.visuals.Add(visual); } initialised = true; } } void OnExpand(object sender, RoutedEventArgs e) { this.OnChangeOffsets(true); } void OnCollapse(object sender, RoutedEventArgs e) { this.OnChangeOffsets(false); } void OnChangeOffsets(bool expand) { var radius = (this.parentGrid.ActualHeight / 2.0d) * 0.4d; var angle = 2.0 * Math.PI / NUM_CIRCLES; for (int i = 0; i < NUM_CIRCLES; i++) { float incrementX = (float)(radius * Math.Cos(i * angle)); float incrementY = (float)(radius * Math.Sin(i * angle)); if (!expand) { incrementX = 0 - incrementX; incrementY = 0 - incrementY; } visuals[i].Offset = new System.Numerics.Vector3( visuals[i].Offset.X + incrementX, visuals[i].Offset.Y + incrementY, 0); } } bool initialised; List<Visual> visuals; static readonly int NUM_CIRCLES = 30; static readonly int RADIUS = 30; Compositor compositor; } }
and what I like about this is that the code in the function OnChangeOffsets only has to deal with changing the value of the Offset property (X,Y,Z) on the Visuals. It does not have to ‘remember’ that there are a bunch of animations that need to be fired, that’s already been set up by code which is largely orthogonal to this code.
By the way – I think the constant RADIUS in that code might be better called DIAMETER – just noticed that!
I also liked the idea that I can give a single animation to all of my Visuals and I can use the “this.FinalValue” nomenclature to signify “the value to which the property has been set”. I must admit that I didn’t think this was likely to work when I first tried it out and so it was nice to find that my hope matched reality in terms of just creating one animation and one easing function.
The other thing that I like is that the animations aren’t running on my UI thread so I don’t have to worry about that and the end result looks smooth.
Comparing with a Pure XAML Implementation
I did wonder though how this might compare to working entirely at the XAML level and so I thought that I’d quickly rewrite the code so as to have a comparison.
I made one change to my “UI” – I changed the Grid named “parentGrid” to be a Canvas named “parentCanvas” and then I rewrote a chunk of the code behind;
using System; using Windows.UI; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Media; using Windows.UI.Xaml.Media.Animation; using Windows.UI.Xaml.Shapes; public sealed partial class MainPage : Page { public MainPage() { this.InitializeComponent(); this.Loaded += OnLoaded; } void OnLoaded(object sender, RoutedEventArgs e) { var brush = new SolidColorBrush(Colors.Red); for (int i = 0; i < NUM_CIRCLES; i++) { var ellipse = new Ellipse() { Width = DIAMETER, Height = DIAMETER, Fill = brush }; Canvas.SetLeft(ellipse, this.parentCanvas.ActualWidth / 2.0d - (DIAMETER / 2.0d)); Canvas.SetTop(ellipse, this.parentCanvas.ActualHeight / 2.0d - (DIAMETER / 2.0d)); this.parentCanvas.Children.Add(ellipse); } this.Initialise(); } void Initialise() { if (!initialised) { this.storyboard = new Storyboard(); var easingFunction = new CubicEase() { EasingMode = EasingMode.EaseIn }; foreach (var child in this.parentCanvas.Children) { var ellipse = child as Ellipse; var animationX = new DoubleAnimation() { Duration = TimeSpan.FromMilliseconds(250), EasingFunction = easingFunction, By = 0, }; var animationY = new DoubleAnimation() { Duration = TimeSpan.FromMilliseconds(250), EasingFunction = easingFunction, By = 0 }; Storyboard.SetTarget(animationX, ellipse); Storyboard.SetTargetProperty(animationX, "(Canvas.Left)"); Storyboard.SetTarget(animationY, ellipse); Storyboard.SetTargetProperty(animationY, "(Canvas.Top)"); this.storyboard.Children.Add(animationX); this.storyboard.Children.Add(animationY); } initialised = true; } } void OnExpand(object sender, RoutedEventArgs e) { this.OnChangeOffsets(true); } void OnCollapse(object sender, RoutedEventArgs e) { this.OnChangeOffsets(false); } void OnChangeOffsets(bool expand) { var radius = (this.parentCanvas.ActualHeight / 2.0d) * 0.4d; var angle = 2.0 * Math.PI / NUM_CIRCLES; for (int i = 0; i < NUM_CIRCLES; i++) { float incrementX = (float)(radius * Math.Cos(i * angle)); float incrementY = (float)(radius * Math.Sin(i * angle)); if (!expand) { incrementX = 0 - incrementX; incrementY = 0 - incrementY; } var animationIndex = i * 2; ((DoubleAnimation)this.storyboard.Children[animationIndex]).By = incrementX; ((DoubleAnimation)this.storyboard.Children[animationIndex + 1]).By = incrementY; } this.storyboard.Begin(); } bool initialised; static readonly int NUM_CIRCLES = 30; static readonly int DIAMETER = 30; Storyboard storyboard; }
There’s a couple of differences from the coding perspective;
- I end up creating NUM_CIRCLES * 2 animations = 60 animations in one Storyboard whereas in the composition case I created one animation.
- The implementation of OnChangeOffsets becomes a process of adjusting the By values of those animations and then running the Storyboard which contains them whereas in the composition case that function became focused on changing the property values that I wanted to change.
but it’s not radically different and I don’t (for this tiny example) see much difference in the runtime behaviour of the app although I will say that the composition example does feel a little smoother to me.
I’m keen to dig into this a little more in the future so may have more posts to share on it but my first trial here was a positive one