Notes: Building the advanced sample requires the Silverlight Toolkit [^]. Compiled for Silverlight 4 (desktop). Source must be recompiled for use in Windows Phone 7 apps.
Contents
Introduction
With the introduction of Windows Phone 7 [^], many developers have found new and interesting ways to design and present their applications. Through the use of beautiful typography, geometric designs and subtle use of motion, the Metro design language [^] enables us to create user experiences that are modern, clean and consistent.
One of the characteristic effects of this design language is the well-known "turnstile" transition, featured in the Windows Phone 7 start screen and in some applications that come with the phone. This animation consists in rotating each element of the screen in 3D in succession in such a way that the whole page seems to move fluidly - an image is worth a thousand words:
In this effect, there's also an element of randomness - the animation is slightly different every time. It's also important to note that the turnstile animation has 4 possible directions:
This article is divided in two parts. In the first part, we'll deconstruct the effect in the simplest possible way. To do that; we'll reproduce the effect in a "controlled scenario", in which we build all the elements knowing their positions. In the second part, a refactored and reusable version of this effect will be presented in the form of an ItemsControl
hat can use any panel for positioning the elements. In one sentence, "Make it work, then make it right".
Part 1 - Make It Work
Positioning the Tiles
To reproduce the turnstile effect, we'll start by creating a Canvas and positioning some tiles in it using absolute positioning (Canvas.Top
Canvas.Left
. This will simplify our code and let us focus on the animation itself. The following XAML creates the Canvas
nd positions some styled borders over it:
[Simple.xaml]: (modified)
<UserControl x:Class="VirtualDreams.TurnstileSimple.Simple"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="240" Height="461">
<UserControl.Resources>
<Style TargetType="Border">
<Setter Property="Background" Value="#1BA1E2" />
<Setter Property="Width" Value="88" />
<Setter Property="Height" Value="88" />
<Setter Property="Canvas.Left" Value="13" />
</Style>
</UserControl.Resources>
<Canvas x:Name="root" Background="Black">
<Border Canvas.Top="47" />
<Border Canvas.Top="47" Canvas.Left="106"/>
<Border Canvas.Top="140" />
<Border Canvas.Top="140" Canvas.Left="106" />
<Border Width="181" Canvas.Top="233" />
<Border Canvas.Top="326" />
<Border Canvas.Top="326" Canvas.Left="106" />
</Canvas>
</UserControl>
The page described above looks like the following strangely familiar layout:
How the Animation Works
The turnstile motion's key characteristic is that each element starts rotating slightly after the other, with elements on the right side starting first. To create that effect, we'll apply a PlaneProjection
o each tile and rotate all of them with similar animations, changing the BeginTime
f each animation according to the tile's position. Finally, we must add a random factor to this BeginTime
so that each animation is slightly different from the previous one.
The method we'll use will consist of the following steps:
- Prepare each child of the
Canvas
y adding a PlaneProjection
nd setting its CenterOfRotationX
o the left edge of the Canvas
/li>
- Calculate the
BeginTime
or each tile based on its position + a random factor
- Apply a
DoubleAnimation
n the RotationY
roperty of the projection using the BeginTime
alculated beforehand
- Apply a
DoubleAnimation
n the Opacity
roperty of the tile to make it fade out as it leaves the screen
First Try
Next, let's implement the code that executes the animation. To keep it simple, we'll only reproduce the effect on the Bottom/Front direction by creating some methods directly in the code-behind file. Follow the comments below for a detailed explanation of the process:
[Simple.xaml.cs]: (modified)
private bool _childrensProjectionsReady = false;
private Random _random = new Random();
public void AnimateTiles()
{
if (!_childrensProjectionsReady)
PrepareChildren(root);
foreach (var tile in root.Children)
{
var projection = tile.Projection as PlaneProjection;
projection.RotationY = 0;
tile.Opacity = 1;
var yFromBottom = root.RenderSize.Height - Canvas.GetTop(tile);
var easing = new QuadraticEase() { EasingMode = EasingMode.EaseInOut };
var duration = TimeSpan.FromMilliseconds(600);
var rotationAnimation = new DoubleAnimation
{ From = 0, To = 90, EasingFunction = easing };
rotationAnimation.Duration = duration;
rotationAnimation.BeginTime = duration.Multiply
(GetBeginTimeFactor(Canvas.GetLeft(tile), yFromBottom));
rotationAnimation.BeginAnimation(projection, PlaneProjection.RotationYProperty);
rotationAnimation.AutoReverse = true;
var opacityAnimation = new DoubleAnimation { To = 0, EasingFunction = easing };
opacityAnimation.Duration = duration.Multiply(0.6);
opacityAnimation.BeginTime = rotationAnimation.BeginTime +
duration - opacityAnimation.Duration.TimeSpan;
opacityAnimation.BeginAnimation(tile, UIElement.OpacityProperty);
opacityAnimation.AutoReverse = true;
}
}
public void PrepareChildren(Panel targetPanel)
{
foreach (var child in targetPanel.Children)
{
var projection = new PlaneProjection
{
CenterOfRotationX = -1 * Canvas.GetLeft(child) / child.RenderSize.Width
};
child.Projection = projection;
}
_childrensProjectionsReady = true;
}
private double GetBeginTimeFactor(double x, double y)
{
const double xFactor = -4.7143E-4;
const double yFactor = 0.001714;
const double randomFactor = 0.0714;
return y * yFactor + x * xFactor + _random.Next(-1, 1) * randomFactor;
}
This code uses the following extension methods:
[ExtensionMethods.cs] (modified):
public static void BeginAnimation(this Timeline animation,
DependencyObject target, object propertyPath)
{
animation.SetTargetAndProperty(target, propertyPath);
var sb = new Storyboard();
sb.Children.Add(animation);
sb.Begin();
}
public static void SetTargetAndProperty
(this Timeline animation, DependencyObject target, object propertyPath)
{
Storyboard.SetTarget(animation, target);
Storyboard.SetTargetProperty(animation, new PropertyPath(propertyPath));
}
public static TimeSpan Multiply(this TimeSpan timeSpan, double factor)
{
return new TimeSpan((long)(timeSpan.Ticks * factor));
}
To execute the animation, let's add an event handler to the root UserControl
s MouseLeftButtonUp
vent that executes the method created above:
[Simple.xaml]:
<UserControl [...] MouseLeftButtonUp="Clicked" >
[Simple.xaml.cs]:
public void Clicked(object sender, MouseButtonEventArgs e)
{
AnimateTiles();
}
Now you can run the animation by clicking anywhere in the page:
Looks good, doesn't it?
Well... not yet! The problem with this animation is that the tiles seem to have a wrong perspective during the animation, as if we were looking to each tile from a straight angle, creating a weird effect. Let's compare our animation with the actual screenshot from Windows Phone 7 to understand this issue (ignore the "Me" icon which was tilted):
The above is trying to show that in WP7 the perspective of each tile is relative to the whole screen, while in our case each tile has its own perspective (solid lines are tile edges, dashed lines are symmetry lines of the perspective). The devil is in the details!
Second Try
Although we're very close to the desired effect, we must still tweak the perspective of each tile to make it relative to the parent Canvas
To do that, the trick is to use the LocalOffsetY
roperty of the PlaneProjection
This property offsets the element vertically from the perspective focal point by a specified amount in pixels. This will effectively translate the element, so to counteract this translation, we'll apply a TranslateTransform
The final result should be that the element stays in its original position, but its perspective during the animation is relative to the Canvas's midpoint.
The changes in the code are the following:
[Simple.xaml.cs]:
public void PrepareChildren(Panel targetPanel)
{
foreach (var child in targetPanel.Children)
{
var projection = new PlaneProjection
{
CenterOfRotationX = -1 * Canvas.GetLeft(child) / child.RenderSize.Width,
LocalOffsetY = Canvas.GetTop(child) - targetPanel.RenderSize.Height / 2
};
child.Projection = projection;
child.RenderTransform = new TranslateTransform
{ Y = -1 * projection.LocalOffsetY };
}
_childrensProjectionsReady = true;
}
Now you can run it again:
YES! It's perfect! Now we can move to making it work with other things but a Canvas
..
Part 2 - Make It Right
In order to make this effect reusable, we'll develop an ItemsControl
hat supports anything as ItemsPanel
To do that, we have to accomplish the following:
- The control doesn't know anything about the how tiles are arranged.
- The animation must target only visible tiles.
- The user must be able to choose the direction in both the Y axis (Top => Bottom or Bottom => Top) and Z axis (Front => Back or Back => Front), besides being able to choose if tiles are entering or exiting the control.
Creating the ItemsControl
The first step is to create the Turnstile as an ItemsControl
nd replacing everything that assumes previous knowledge of the position of the tiles by methods that work everytime for any element. To do that, instead of preparing the items once and assuming they won't change, we'll prepare them when they're created by overriding the PrepareContainerForItemOverride
ethod. Also, we must take into account any layout changes in the control to calculate the tile offsets, so we'll override the ArrangeOverride
ethod and store the offsets there. Let's see what that looks like:
[Turnstile.xaml.cs] (modified, xml-doc and convenience overloads removed):
public class Turnstile : ItemsControl
{
IDictionary<FrameworkElement, Point> _positions;
Random _random = new Random();
protected override Size ArrangeOverride(Size finalSize)
{
var size = base.ArrangeOverride(finalSize);
_positions = new Dictionary<FrameworkElement, Point>();
foreach (var item in Items.Select(i =>
ItemContainerGenerator.ContainerFromItem(i))
.OfType<FrameworkElement>()
.Where(el => el.ActualWidth > 0.0))
{
var offset = item.TransformToVisual(this).Transform(new Point(0, 0));
_positions.Add(item, offset);
var projection = item.Projection as PlaneProjection;
projection.CenterOfRotationX = -1 * offset.X / item.ActualWidth;
projection.LocalOffsetY = offset.Y - size.Height / 2;
(item.RenderTransform as TranslateTransform).Y =
-1 * projection.LocalOffsetY;
}
return size;
}
protected override void
PrepareContainerForItemOverride(DependencyObject element, object item)
{
base.PrepareContainerForItemOverride(element, item);
var container = element as UIElement;
if (container != null)
{
container.Projection = new PlaneProjection();
container.RenderTransform = new TranslateTransform();
}
}
public void AnimateTiles
(EnterMode mode, YDirection yDirection, ZDirection zDirection, TimeSpan duration)
{
if (ActualWidth <= 0 || ActualHeight <= 0 ||
_positions == null || _positions.Count <= 0) return;
var visibleTiles = _positions.Where(x => x.Value.X + x.Key.ActualWidth >= 0 &&
x.Value.X <= ActualWidth &&
x.Value.Y + x.Key.ActualHeight >= 0 &&
x.Value.Y <= ActualHeight);
if (visibleTiles.Count() <= 0) return;
double lowestY = visibleTiles.Max(el => el.Value.Y);
var animations = new List<Timeline>();
foreach (var tilePosition in visibleTiles)
{
var tile = tilePosition.Key;
var position = tilePosition.Value;
var projection = tile.Projection as PlaneProjection;
double rotationFrom, rotationTo, opacityTo;
if (mode == EnterMode.Exit)
{
tile.Opacity = 1;
opacityTo = 0;
rotationFrom = 0;
rotationTo = zDirection == ZDirection.BackToFront ? -90 : 90;
}
else
{
tile.Opacity = 0;
opacityTo = 1;
rotationFrom = zDirection == ZDirection.BackToFront ? -90 : 90;
rotationTo = 0;
}
double relativeY;
if (yDirection == YDirection.BottomToTop)
{
relativeY = lowestY - position.Y;
}
else
{
relativeY = position.Y;
}
var easing = new QuadraticEase() { EasingMode = EasingMode.EaseInOut };
var rotationAnimation = new DoubleAnimation
{ From = rotationFrom, To = rotationTo, EasingFunction = easing };
rotationAnimation.Duration = duration;
rotationAnimation.BeginTime = duration.Multiply
(GetBeginTimeFactor(position.X, relativeY, mode));
rotationAnimation.SetTargetAndProperty(projection,
PlaneProjection.RotationYProperty);
var opacityAnimation = new DoubleAnimation
{ To = opacityTo, EasingFunction = easing };
opacityAnimation.Duration = duration.Multiply(0.6);
opacityAnimation.BeginTime = rotationAnimation.BeginTime;
if (mode == EnterMode.Exit)
opacityAnimation.BeginTime += duration -
opacityAnimation.Duration.TimeSpan;
opacityAnimation.SetTargetAndProperty(tile, UIElement.OpacityProperty);
animations.Add(rotationAnimation);
animations.Add(opacityAnimation);
}
var sb = new Storyboard();
foreach (var a in animations)
sb.Children.Add(a);
sb.Begin();
}
private double GetBeginTimeFactor(double x, double y, EnterMode mode)
{
const double xFactor = 4.7143E-4;
const double yFactor = 0.001714;
const double randomFactor = 0.0714;
var columnFactor = mode == EnterMode.Enter ? xFactor : -1 * xFactor;
return y * yFactor + x * columnFactor + _random.Next(-1, 1) * randomFactor;
}
}
Some interesting points (highlighted on the code):
- During arrange, we store each item's offset in a dictionary.
- We create the transforms and projections when the containers are created for the items (
PrepareContainerForItemOverride
.
- We use three enumerations to provide the parameters to the function.
- Through a LINQ query, we select only visible elements.
The definition of the enum
is the following:
[Enums.xaml.cs] (modified):
public enum EnterMode { Enter, Exit }
public enum YDirection { TopToBottom, BottomToTop }
public enum ZDirection { FrontToBack, BackToFront }
Using the Control
Our final step is to put this control in action. To do that, I've reconstructed the familiar home screen layout using the Turnstile and a WrapPanel
from the Silverlight Toolkit). The items inside the WrapPanel
ave margins to avoid being clipped by the parent WrapPanel
Some HyperlinkButton
were also added to trigger the different animations
Just for fun, I've experimented with a 3-column layout instead of the 2-column layout we're used to:
[MainPage.xaml] (modified, styles and boilerplate removed):
<UserControl ...
xmlns:vd="clr-namespace:VirtualDreams.Turnstile;assembly=VirtualDreams.Turnstile>
<Grid Background="{StaticResource ApplicationBarBrush}">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="37"/>
</Grid.RowDefinitions>
<Border Style="{x:Null}" Background="{StaticResource BackgroundBrush}">
<vd:Turnstile x:Name="turnstile">
<vd:Turnstile.ItemsPanel>
<ItemsPanelTemplate>
<tk:WrapPanel Margin="13,0,0,0"/>
</ItemsPanelTemplate>
</vd:Turnstile.ItemsPanel>
<Border Style="{StaticResource FirstRowTileStyle}" />
<Border Style="{StaticResource FirstRowTileStyle}" />
<Border Style="{StaticResource FirstRowTileStyle}" />
<Border />
<Border Style="{StaticResource LargeTileStyle}" />
<Border />
<Border />
<Border />
<Border Style="{StaticResource LargeTileStyle}" />
<Border />
-->
</vd:Turnstile>
</Border>
<Grid Grid.Row="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Column="1">Animate:</TextBlock>
<HyperlinkButton Click="AnimateTopFront" Grid.Column="2">
Top/Front</HyperlinkButton>
<HyperlinkButton Click="AnimateBottomFront" Grid.Column="3">
Bottom/Front</HyperlinkButton>
<HyperlinkButton Click="AnimateTopBack" Grid.Row="1" Grid.Column="2">
Top/Back</HyperlinkButton>
<HyperlinkButton Click="AnimateBottomBack" Grid.Row="1"
Grid.Column="3">Bottom/Back</HyperlinkButton>
</Grid>
</Grid>
</UserControl>
[MainPage.xaml.cs] (modified, repetitive code removed):
public partial class MainPage : UserControl
{
public MainPage()
{
InitializeComponent();
SizeChanged += (s,e) => turnstile.AnimateTiles
(_currentEnterMode, YDirection.BottomToTop, ZDirection.FrontToBack);
}
EnterMode _currentEnterMode = EnterMode.Enter;
private void AnimateTopFront(object sender, RoutedEventArgs e)
{
AnimateTiles(YDirection.TopToBottom, ZDirection.FrontToBack);
}
private void AnimateTiles(YDirection yDirection, ZDirection zDirection)
{
_currentEnterMode = _currentEnterMode ==
EnterMode.Enter ? EnterMode.Exit : EnterMode.Enter;
turnstile.AnimateTiles(_currentEnterMode, yDirection, zDirection);
}
}
The resulting app:
Wrapping Up
In this article, we developed the Turnstile animation from Windows Phone 7 in Silverlight, making it easy to reuse as an ItemsControl
All the code is also available on Codeplex [^], so feel free to tweak and improve this control as you wish.
I hope that this article has sparked some ideas on how to improve your interfaces by paying attention to details. If you develop something using this animation, post the link in the comments section!
For the Future
Some interesting next steps for this app could be:
- Implementing the "tilt" animation when an item is clicked and making its animation to begin later for a closer emulation of the animation as it's presented on the phone.
- Developing a special
TransitioningContentControl
hat automatically applies the effect to all visible children recursively whenever the content changes. This would allow easy use in navigation applications, for example.
- Implementing Blend triggers to make it easier to fire the animations without writing code.
- Implementing the live tiles, flipping tiles, etc.
- Improving performance and memory usage for cases where many items are displayed in the control.
What Do You Think?
Was this project useful? How would you implement a reusable turnstile?
Please leave your comments and suggestions, and please vote up if this article pleases you. Thanks!
Other Links and References
History
- v1.0 (12/10/2010) - Initial release