The Animated Pie Slice in Windows 8
March 30, 2012
New York, N.Y.
Two of my favorite animation classes in the Windows Presentation Foundation are PointAnimationUsingPath and MatrixAnimationUsingPath. Both these animations define a property of type PathGeometry that lets you animate a point along a complex series of connected lines, arcs, and Bezier curves. They even let you obtain a tangent to this line so you can rotate animated objects to be tangent or perpendicular to the path. I used MatrixAnimationUsingPath to move a unicycle around a two-dimensional terrain, and I used PointAnimationUsingPath to make a pie slice animation.
Well, that was a long time ago. In the past several years I haven't been coding for WPF as much as I've been coding for Silverlight, and then Silverlight for Windows Phone, and now the Windows Runtime of Windows 8 (which might deviously be called "Silverlight for Windows"). Silverlight and WinRT are missing a lot of the cooler stuff in WPF. Consequently, those of us whose minds have been expanded by the wealth of WPF goodies are sometimes forced to code klunky work-arounds.
I got an email this morning from someone asking if I could adapt that pie slice animation for Windows 8, and of course I had to drop everything I was doing to try to figure it out.
Drawing a pie slice in WPF, Silverlight, and the WinRT involves creating a PathGeometry that consists of two straight lines from the center of the pie to the circumference (the second of these lines can be drawn automatically to close the figure) and an ArcSegment. The ArcSegment draws an arc between two points based on a particular ellipse size, a SweepDirection (clockwise or counter-clockwise) and a Boolean IsLargeArc property. This ArcSegment is great if you just have two points and you want to connect them with an arc of a particular curvature. But it's not so great if you need to specify the center of the circle (or ellipse). In that case, the two points need to be on the circumference of a circle at that center, and they need to be calculated.
An animated pie slice requires moving a point along the circumference of a circle. It's easiest to animate a sweep angle from 0 to 360 degrees, and then calculate a point on the circumference from that. The calculation is trivial, but for coding purposes the real question is where should that calculation be performed.
I knew I couldn't actually create my own animation classes as you can in WPF, so my first thought was to do something similar to my attempt to reproduce MatrixAnimationUsingPath in Silverlight with the handy CompositionTarget.Rendering event.
But I knew that wasn't a good solution either, because the pie slice requires a second animation: The IsLargeArc property needs to be flipped from false to true when the sweep angle is greater than 180 degrees. You definitely want the two animations to be performed in synchronization or glitches will result.
Moreover, there are other good reasons to make use of WinRT's built-in animation, for example, to use easing functions if those are also desired.
So I began gravitating around the idea of creating a class that would be a target of a WinRT animation and perform the necessary calculations and provide this calculated value to the rest of the application. And I created this class called CircleGenerator:
public class CircleGenerator : FrameworkElement
{
public static DependencyProperty CenterProperty { private set; get; }
public static DependencyProperty RadiusProperty { private set; get; }
public static DependencyProperty AngleProperty { private set; get; }
public static DependencyProperty PointProperty { private set; get; }
static CircleGenerator()
{
CenterProperty = DependencyProperty.Register("Center",
typeof(Point), typeof(CircleGenerator),
new PropertyMetadata(new Point(), OnPropertyChanged));
RadiusProperty = DependencyProperty.Register("Radius",
typeof(double), typeof(CircleGenerator),
new PropertyMetadata(0.0, OnPropertyChanged));
AngleProperty = DependencyProperty.Register("Angle",
typeof(double), typeof(CircleGenerator),
new PropertyMetadata(0.0, OnPropertyChanged));
PointProperty = DependencyProperty.Register("Point",
typeof(Point), typeof(CircleGenerator),
new PropertyMetadata(new Point()));
}
public Point Center
{
set { SetValue(CenterProperty, value); }
get { return (Point)GetValue(CenterProperty); }
}
public double Radius
{
set { SetValue(RadiusProperty, value); }
get { return (double)GetValue(RadiusProperty); }
}
public double Angle
{
set { SetValue(AngleProperty, value); }
get { return (double)GetValue(AngleProperty); }
}
public Point Point
{
protected set { SetValue(PointProperty, value); }
get { return (Point)GetValue(PointProperty); }
}
static void OnPropertyChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
(obj as CircleGenerator).OnPropertyChanged(args);
}
void OnPropertyChanged(DependencyPropertyChangedEventArgs args)
{
double x = this.Center.X +
this.Radius * Math.Sin(Math.PI * this.Angle / 180);
double y = this.Center.Y -
this.Radius * Math.Cos(Math.PI * this.Angle / 180);
this.Point = new Point(x, y);
}
This class derives from FrameworkElement so you can put it right in the visual tree of the XAML file. The Center and Radius properties can be set to fixed values, and Angle (being backed by a dependency property) can be the target of a XAML DoubleAnimation that goes from 0 to 360. Whenever the Angle property changes, the Point property is recalculated.
I could then define a binding on the Point property of ArcSegment to the Point property of CircleGenerator.
And that's when I discovered that WinRT restricts binding targets to properties of FrameworkElement derivatives.
CORRECTION (4/2/12): Not exactly true. After more experimentation, here's my experience:
Source: WinRT element / Target: FrameworkElement derivative --> Binding works
Source: WinRT element / Target: DependencyObject derivative --> Binding works
Source: Custom element / Target: FrameworkElement derivative --> Binding works
Source: Custom element / Target: DependencyObject derivative --> Binding does not work
Good grief! Didn't Silverlight fix that problem about 4 versions ago?
At any rate, my CircleGenerator class got more complex. I realized that it would need TargetName and TargetProperty properties just like Storyboard so the CircleGenerator object itself could directly animate the Point property of the ArcSegment.
And it was around this time that I realized that animating IsLargeArc was also a problem because there's no such thing as a BooleanAnimationUsingKeyFrames in WinRT.
That led me to write a base class called ValuePusher that defines TargetName and TargetProperty properties, as well as a protected method named PushValue:
public class ValuePusher : FrameworkElement
{
public DependencyObject targetObject;
public DependencyProperty targetProperty;
public static DependencyProperty TargetNameProperty { private set; get; }
public static DependencyProperty TargetPropertyProperty { private set; get; }
static ValuePusher()
{
TargetNameProperty = DependencyProperty.Register("TargetName",
typeof(string), typeof(ValuePusher),
new PropertyMetadata(null, OnPropertyChanged));
TargetPropertyProperty = DependencyProperty.Register("TargetProperty",
typeof(string), typeof(ValuePusher),
new PropertyMetadata(null, OnPropertyChanged));
}
static void OnPropertyChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
(obj as ValuePusher).targetObject = null;
(obj as ValuePusher).targetProperty = null;
}
public string TargetName
{
set { SetValue(TargetNameProperty, value); }
get { return (string)GetValue(TargetNameProperty); }
}
public string TargetProperty
{
set { SetValue(TargetPropertyProperty, value); }
get { return (string)GetValue(TargetPropertyProperty); }
}
protected void PushValue(object value)
{
if (targetObject == null &&
!String.IsNullOrEmpty(this.TargetName))
{
targetObject = this.FindName(this.TargetName) as DependencyObject;
}
if (targetObject != null &&
targetProperty == null &&
!String.IsNullOrEmpty(this.TargetProperty))
{
TypeInfo typeInfo = this.targetObject.GetType().GetTypeInfo();
PropertyInfo propInfo =
typeInfo.GetDeclaredProperty(this.TargetProperty + "Property");
targetProperty = propInfo.GetValue(null) as DependencyProperty;
}
if (targetObject != null && targetProperty != null)
{
targetObject.SetValue(targetProperty, value);
}
}
}
Notice that the target object and dependency property being targetted aren't actually determined until they're required by a call to PushValue. The problem is the FindName call used to obtain the named object in the XAML file. This will not be available until the page is completely loaded, which occurs after the TargetName property is set during XAML parsing.
The new version of CircleGenerator derives from ValuePusher:
public class CircleGenerator : ValuePusher
{
public static DependencyProperty CenterProperty { private set; get; }
public static DependencyProperty RadiusProperty { private set; get; }
public static DependencyProperty AngleProperty { private set; get; }
public static DependencyProperty PointProperty { private set; get; }
static CircleGenerator()
{
CenterProperty = DependencyProperty.Register("Center",
typeof(Point), typeof(CircleGenerator),
new PropertyMetadata(new Point(), OnPropertyChanged));
RadiusProperty = DependencyProperty.Register("Radius",
typeof(double), typeof(CircleGenerator),
new PropertyMetadata(0.0, OnPropertyChanged));
AngleProperty = DependencyProperty.Register("Angle",
typeof(double), typeof(CircleGenerator),
new PropertyMetadata(0.0, OnPropertyChanged));
PointProperty = DependencyProperty.Register("Point",
typeof(Point), typeof(CircleGenerator),
new PropertyMetadata(new Point()));
}
public Point Center
{
set { SetValue(CenterProperty, value); }
get { return (Point)GetValue(CenterProperty); }
}
public double Radius
{
set { SetValue(RadiusProperty, value); }
get { return (double)GetValue(RadiusProperty); }
}
public double Angle
{
set { SetValue(AngleProperty, value); }
get { return (double)GetValue(AngleProperty); }
}
public Point Point
{
protected set { SetValue(PointProperty, value); }
get { return (Point)GetValue(PointProperty); }
}
static void OnPropertyChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
(obj as CircleGenerator).OnPropertyChanged(args);
}
void OnPropertyChanged(DependencyPropertyChangedEventArgs args)
{
double x = this.Center.X +
this.Radius * Math.Sin(Math.PI * this.Angle / 180);
double y = this.Center.Y -
this.Radius * Math.Cos(Math.PI * this.Angle / 180);
this.Point = new Point(x, y);
PushValue(this.Point);
}
}
A similar (but simpler) class named BooleanGenerator has only one property of type double, and generates a bool based on whether than value is greater than zero or not:
public class BooleanGenerator : ValuePusher
{
public static DependencyProperty ValueProperty { private set; get; }
static BooleanGenerator()
{
ValueProperty = DependencyProperty.Register("Value",
typeof(double), typeof(BooleanGenerator),
new PropertyMetadata(0.0, OnPropertyChanged));
}
static void OnPropertyChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
bool b = (obj as BooleanGenerator).Value > 0;
(obj as BooleanGenerator).PushValue(b);
}
public double Value
{
set { SetValue(ValueProperty, value); }
get { return (double)GetValue(ValueProperty); }
}
}
And now the XAML is simple: The Storyboard defined as a resource contains two DoubleAnimation definitions, one for the sweep angle and another for the IsLargeArc property. The first animation targets the CircleGenerator and the second targets the BooleanGenerator, both of which are defined right in the visual tree by virtue of deriving from FrameworkElement:
<Page x:Class="AnimatedPieSlice.BlankPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:AnimatedPieSlice">
<Page.Resources>
<Storyboard x:Key="storyboard">
<DoubleAnimation Storyboard.TargetName="circleGen"
Storyboard.TargetProperty="Angle"
EnableDependentAnimation="True"
From="0" To="359"
Duration="0:0:5"
RepeatBehavior="Forever"
AutoReverse="True" />
<DoubleAnimationUsingKeyFrames
Storyboard.TargetName="booleanGen"
Storyboard.TargetProperty="Value"
EnableDependentAnimation="True"
Duration="0:0:5"
RepeatBehavior="Forever"
AutoReverse="True">
<DiscreteDoubleKeyFrame KeyTime="0:0:0" Value="-1" />
<DiscreteDoubleKeyFrame KeyTime="0:0:2.5" Value="1" />
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Page.Resources>
<Grid Background="{StaticResource ApplicationPageBackgroundBrush}">
<local:CircleGenerator Name="circleGen"
TargetName="arcSegment"
TargetProperty="Point"
Center="400 400"
Radius="300" />
<local:BooleanGenerator Name="booleanGen"
TargetName="arcSegment"
TargetProperty="IsLargeArc" />
<Path Stroke="Red"
StrokeThickness="12"
Fill="Blue">
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="400 400" IsClosed="True">
<LineSegment Point="400 100" />
<ArcSegment x:Name="arcSegment"
Size="300 300"
SweepDirection="Clockwise" />
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
</Grid>
</Page>
The CircleGenerator and BooleanGenerator objects target the two properties of ArcSegment. The code-behind file merely starts the Storyboard going, and the roundabout result is a pie-slice animation for Windows 8:
And here's the source code.