Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Windows Phone 7 Turnstile Control for Silverlight

0.00/5 (No votes)
11 Oct 2010 1  
A tutorial explaining how to develop the Turnstile animation as featured in the Windows Phone 7 UI, along with an easy-to-use ItemsControl that applies the effect to its items.

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.

Windows Phone 7 Turnstile Screenshot (Click to go to the demo)

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:

Windows Phone 7 Home Screen

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:

Turnstile animation 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.TopCanvas.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:

Positioning the tiles

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:

  1. Prepare each child of the Canvas y adding a PlaneProjection nd setting its CenterOfRotationX o the left edge of the Canvas /li>
  2. Calculate the BeginTime or each tile based on its position + a random factor
  3. Apply a DoubleAnimation n the RotationY roperty of the projection using the BeginTime alculated beforehand
  4. 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)

// Used to determine if the children have already been prepared 
// for the animation with the required projections.
private bool _childrensProjectionsReady = false;

// Used to calculate a random factor applied to each animation
private Random _random = new Random();

// Executes a Turnstile animation that makes the tiles
// exit to the front, starting from the bottom, and come back
public void AnimateTiles()
{
    if (!_childrensProjectionsReady)
        PrepareChildren(root);

    foreach (var tile in root.Children)
    {
        var projection = tile.Projection as PlaneProjection;

        // Reset all children regardless of their animations
        projection.RotationY = 0;
        tile.Opacity = 1;

        // Calculate the Y position starting from the bottom
        var yFromBottom = root.RenderSize.Height - Canvas.GetTop(tile);

        var easing = new QuadraticEase() { EasingMode = EasingMode.EaseInOut };

        // Duration of the animation of each tile, not the total duration
        var duration = TimeSpan.FromMilliseconds(600);
                
        var rotationAnimation = new DoubleAnimation 
		{ From = 0, To = 90, EasingFunction = easing };
        rotationAnimation.Duration = duration;
        // The key to this animation is getting the BeginTime 
        // right so that each tile starts slightly
        // after the previous one, in the correct order and building the correct "shape"
        rotationAnimation.BeginTime = duration.Multiply
		(GetBeginTimeFactor(Canvas.GetLeft(tile), yFromBottom));
        rotationAnimation.BeginAnimation(projection, PlaneProjection.RotationYProperty);
        rotationAnimation.AutoReverse = true; // to make it come back automatically

        var opacityAnimation = new DoubleAnimation { To = 0, EasingFunction = easing };
        // The opacity animation takes the last 60% of the rotation animation
        opacityAnimation.Duration = duration.Multiply(0.6);
        opacityAnimation.BeginTime = rotationAnimation.BeginTime + 
			duration - opacityAnimation.Duration.TimeSpan;
        opacityAnimation.BeginAnimation(tile, UIElement.OpacityProperty);
        opacityAnimation.AutoReverse = true;
    }
}

// Prepares each child of a panel with the appropriate
// projections that will enable us to control its rotation during 
// the turnstile animation.
public void PrepareChildren(Panel targetPanel)
{
    foreach (var child in targetPanel.Children)
    {
        var projection = new PlaneProjection
        {
            // The CenterOfRotationX property determines the axis around which 
            // the element will be rotated. It's defined in coordinates relative
            // to the element's size (e.g. CenterOfRotationX = 0.5 represents an
            // axis that passes through the middle of the element). In our case we 
            // want to put the axis on the left edge of the parent Canvas.
            CenterOfRotationX = -1 * Canvas.GetLeft(child) / child.RenderSize.Width
        };
        child.Projection = projection;
    }
    _childrensProjectionsReady = true;
}

// Calculates the begin time factor for a tile positioned in
// the X and Y coordinates passed as parameters. If the animation
// starts from the bottom, the Y value should also start from the 
// bottom of the visible area.
private double GetBeginTimeFactor(double x, double y)
{
    // These numbers were tweaked through trial and error.
    // The main idea is that the weight of the Y coordinate must be 
    // much more important than the X coordinate and the randomness factor. 
    // Also, remember that X and Y are in pixels, so in absolute value
    // y * yFactor >> x * xFactor >> randomFactor
    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):

// Creates a Storyboard with this Timeline, sets the target and target property
// accordingly, and begins the animation.
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();
}

// Sets the Storyboard.Target and Storyboard.TargetProperty for this Timeline.
public static void SetTargetAndProperty
	(this Timeline animation, DependencyObject target, object propertyPath)
{
    Storyboard.SetTarget(animation, target);
    Storyboard.SetTargetProperty(animation, new PropertyPath(propertyPath));
}

// Multiplies this TimeSpan by the factor passed as parameter and returns
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 UserControls 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:

Animation after first try

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):

Comparison between actual animation and our first try

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:

Animation after second try

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
{
    // Stores the position of each element inside this 
    // ItemsControl associated to the element itself.
    IDictionary<FrameworkElement, Point> _positions;

    // Used to add a randomness factor to the turnstile animation.
    Random _random = new Random();

    protected override Size ArrangeOverride(Size finalSize)
    {
        var size = base.ArrangeOverride(finalSize);

        // At each arrange we recalculate the positions
        _positions = new Dictionary<FrameworkElement, Point>();
        // Items have been measured, so they should have an ActualWidth if visible
        foreach (var item in Items.Select(i => 
			ItemContainerGenerator.ContainerFromItem(i))
                                  .OfType<FrameworkElement>()
                                  .Where(el => el.ActualWidth > 0.0))
        {
            // Gets the item's position in coordinates of the parent
            var offset = item.TransformToVisual(this).Transform(new Point(0, 0));
            _positions.Add(item, offset);

            var projection = item.Projection as PlaneProjection;

            // Set the center of rotation to the left edge of the ItemsControl
            projection.CenterOfRotationX = -1 * offset.X / item.ActualWidth;
            // Set the perspective so that the central point is the vertical midpoint
            projection.LocalOffsetY = offset.Y - size.Height / 2;

            // Counteract the translation effects of setting LocalOffsetY
            (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();
        }
    }

    // Animates the tiles using the supplied entrance mode, Y direction, 
    // Z direction and duration.
    public void AnimateTiles
	(EnterMode mode, YDirection yDirection, ZDirection zDirection, TimeSpan duration)
    {
        // If the control has not been rendered or it's empty, cancel the animation
        if (ActualWidth <= 0 || ActualHeight <= 0 || 
		_positions == null || _positions.Count <= 0) return;

        // Get the visible tiles for the current configuration
        // Tiles that are partially visible are also counted
        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);

        // No visible tiles, do nothing
        if (visibleTiles.Count() <= 0) return;

        // The Y coordinate of the lowest element is useful 
        // when we animate from bottom to top
        double lowestY = visibleTiles.Max(el => el.Value.Y);

        // Store the animations to group them in one Storyboard in the end
        var animations = new List<Timeline>();

        foreach (var tilePosition in visibleTiles)
        {
            // To make syntax lighter
            var tile = tilePosition.Key;
            var position = tilePosition.Value;
            var projection = tile.Projection as PlaneProjection;

            double rotationFrom, rotationTo, opacityTo;

            // Reset all children's opacity regardless of their animations
            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;
            }

            // Used to determine begin time - 
            // depends if we're moving from bottom or from top
            double relativeY;

            if (yDirection == YDirection.BottomToTop)
            {
                // The lowest element should have relativeY == 0
                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 };
            // The opacity animation takes the last 60% of the rotation animation
            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);
        }

        // Begin all animations
        var sb = new Storyboard();
        foreach (var a in animations)
            sb.Children.Add(a);
        sb.Begin();
    }

    // Gets the BeginTime factor (BeginTime / Duration) 
    // for an animation of a tile based on its position and if it's entering or exiting.
    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;

        // The rightmost element must start first when exiting and last when entering
        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 />
                <!-- more tiles... -->                
            </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);
    }

    //... same for BottomFront, TopBack, BottomBack ...
    
    private void AnimateTiles(YDirection yDirection, ZDirection zDirection)
    {
        _currentEnterMode = _currentEnterMode == 
		EnterMode.Enter ? EnterMode.Exit : EnterMode.Enter;
        turnstile.AnimateTiles(_currentEnterMode, yDirection, zDirection);
    }
}

The resulting app:

Turnstile with 3-column Windows Phone 7 layout

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

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here