Tuesday, March 30, 2010

Using WriteableBitmap to Simplify Animations with Clones

I'm going to deviate from my typical "line of business" blog posts to discuss a topic that comes up quite a bit with Silverlight: animations. I've had a few projects where it's been necessary to use animations to transition between "screens" in the application. While I use the visual state manager as often as I can for this, sometimes the transitions aren't really changing the state of the control itself, but simply animating the change of underlying data.

Download the source code for this project

I've seen examples where people will actually create a second control to populate with the data, then animate it to the foreground and destroy the old control. While this works, there is a technique that is a lot easier, simpler, and uses far less resources. In fact, while I'm going to introduce it here as a more "manual" process, it's only a few extra lines of code to abstract the entire thing into a behavior you can attach.

Before we jump into the details, let's set up a reference project that is completely contrived. I'm going to have a collection of paragraphs and demonstrate a sort of "page flip" technique to swap between paragraphs.

The Setup

First, let me define a "paragraph" which is simply a line of text. Because I'm keeping it in a collection, I want to make sure that Equals and GetHashCode know how to handle it appropriately.

public class Paragraph
{ 
   public string Text { get; set; }

   public override bool Equals(object obj)
   {
      return obj is Paragraph && ((Paragraph) obj).Text.Equals(Text);
   }

   public override int GetHashCode()
   {
      return Text.GetHashCode();
   }
}

Now I'll encapsulate a set of paragraphs in another container, which is my "book" so to speak. The name will give away where the text is coming from:

public class Lorem
{
   public Lorem()
   {
      Ipsum = new ObservableCollection<Paragraph>();
   }

   public ObservableCollection<Paragraph> Ipsum { get; set; } 
}

Because my data won't be changing much, the easiest way for me to grab it will be simply to have it serialized right in the project. To do this, I simply add a resource dictionary and fill in my objects. I chose to grab 5 paragraphs of Lorem Ipsum for the example:

<ResourceDictionary
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
   xmlns:AnimationExample="clr-namespace:AnimationExample">
      <AnimationExample:Lorem x:Key="MainLorem">
         <AnimationExample:Lorem.Ipsum>
            <AnimationExample:Paragraph Text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In neque justo, lobortis ut fermentum at, ultricies vitae lectus. Fusce sit amet mauris orci, eget pulvinar erat. Morbi interdum rutrum justo a faucibus. Aliquam malesuada, nunc id convallis ullamcorper, nunc est posuere tellus, et commodo diam magna id nulla. Nam pretium erat eget sem porta rhoncus quis et leo. Vivamus ornare tincidunt bibendum. Cras varius sagittis sapien, vitae pellentesque orci fringilla sed. Donec ultrices justo quis tellus auctor dignissim. Mauris id rhoncus ante. Mauris elementum, diam non blandit blandit, elit libero sodales tellus, vitae aliquam tellus tortor tempus enim. Ut iaculis tincidunt lacus, in lacinia elit laoreet ut. Vivamus ut velit dui. Sed consectetur feugiat tristique. Fusce velit mi, sodales non sagittis laoreet, viverra in urna. Donec nisi mauris, luctus ut mattis vel, adipiscing vel justo. Aliquam molestie fringilla risus, in blandit lorem eleifend eu. Sed imperdiet egestas elit quis varius. Vestibulum fringilla vestibulum posuere. Phasellus massa augue, laoreet tempus scelerisque id, euismod eu orci."/>
            <AnimationExample:Paragraph Text="Suspendisse sit amet felis lorem. Phasellus cursus rhoncus leo, nec fringilla augue tempus a. Cras nec lectus nunc. In hac habitasse platea dictumst. Nullam lobortis, lorem non aliquam faucibus, mauris ligula scelerisque dui, sit amet euismod tellus nisl sit amet lacus. Mauris tempus, neque ac posuere congue, eros nisi hendrerit risus, luctus venenatis massa urna id diam. Quisque in neque magna, quis auctor ipsum. Morbi risus nunc, suscipit at fermentum nec, condimentum ac eros. Curabitur molestie risus non risus dignissim vulputate. Aliquam eget arcu neque. Nam id sem in justo tristique semper ac in elit."/>
            <AnimationExample:Paragraph Text="Vivamus vel aliquam nisi. Vestibulum adipiscing, nunc mattis dictum dapibus, elit quam dapibus augue, vitae adipiscing tortor est blandit erat. Pellentesque pretium vehicula neque sed malesuada. Cras dapibus iaculis nunc, et congue felis accumsan vel. Cras convallis scelerisque ligula vitae auctor. Sed pharetra posuere augue ac aliquet. Vestibulum imperdiet nisl sit amet ante cursus in pretium justo cursus. Praesent fringilla tempus semper. Pellentesque malesuada convallis massa, nec adipiscing nunc aliquet id. Maecenas sodales elit sed velit accumsan faucibus."/>
            <AnimationExample:Paragraph Text="Curabitur rhoncus pulvinar tortor, ultrices elementum nulla ornare sed. Suspendisse sit amet purus quis dui consectetur dapibus. In hac habitasse platea dictumst. Suspendisse quam diam, dapibus eget hendrerit ut, molestie vel tellus. In auctor tellus quis urna pharetra lobortis. Vestibulum a massa eget arcu pretium convallis eu rhoncus nisl. Cras tincidunt felis nec massa vestibulum molestie. Sed nec mauris metus. Nulla nec dictum diam. Maecenas dictum fermentum est, a rhoncus neque suscipit quis. Etiam et leo non metus blandit malesuada id vitae arcu."/>
            <AnimationExample:Paragraph Text="Nullam dignissim porttitor pulvinar. Etiam fringilla viverra iaculis. Pellentesque nec augue urna. Nunc eu eros ac lectus scelerisque pharetra eget a nisi. Morbi mollis dolor sit amet nibh pellentesque eu ullamcorper sapien gravida. Donec at risus eu felis scelerisque tristique eget ut nisi. Suspendisse scelerisque leo vel nibh ornare ultrices. Quisque ac ante lacinia erat dignissim dignissim. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras sit amet purus nibh. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sagittis rhoncus tellus eu molestie. Praesent scelerisque libero sit amet dui ultricies elementum. Integer at ullamcorper leo. Maecenas vulputate ante vel tortor tincidunt vestibulum. Nullam posuere felis ac odio dignissim in congue tortor imperdiet. Praesent vel diam nisl, sodales condimentum justo. Vivamus ultricies, tellus a tincidunt ullamcorper, neque ipsum varius libero, et luctus lorem sem in enim."/>
         </AnimationExample:Lorem.Ipsum>
      </AnimationExample:Lorem>
</ResourceDictionary>

That's the beauty of XAML: I can simply define the object graph right there and let the parser serialize it. I'll add a "helper" extension method that makes it easy for me to grab the instance I want:

public static class Extensions
{
   private const string RESOURCE = "/AnimationExample;component/LoremIpsum.xaml";
   private const string KEY = "MainLorem";

   public static Lorem FromResource(this Lorem lorem)
   {
      var dictionary = new ResourceDictionary
         {Source = new Uri(RESOURCE, UriKind.Relative)};
      return (Lorem) dictionary[KEY]; 
   }
}

As you can see, I'm simply referencing the resource dictionary, then accessing the key. This will let me do this:

...
var lorem = new Lorem().FromResource(); 
...

Now let's build a view model. I'm not going to drag in any frameworks like MEF or PRISM, so the commanding will be manual and code behind for this example. As such, I'll need to expose properties to determine whether or not we can flip the page forward or backward, and a method to perform the actual swap. Here is the view model:

public class ViewModel : INotifyPropertyChanged 
{
   public ViewModel()
   {
      LoremIpsum = new Lorem().FromResource();
      CurrentParagraph = LoremIpsum.Ipsum[0]; 
      RaisePropertyChange("CanGoForward");
      RaisePropertyChange("CanGoBackward");
      Transition = forward => { };
   }

   public Action<bool> Transition { get; set; }

   public bool CanGoForward
   {
      get
      {
         int idx = LoremIpsum.Ipsum.IndexOf(CurrentParagraph);
         return idx < (LoremIpsum.Ipsum.Count - 1);
      }
   }

   public bool CanGoBackward
   {
      get
      {
         int idx = LoremIpsum.Ipsum.IndexOf(CurrentParagraph);
         return idx > 0;
      }
   }

   public void Move(bool forward)
   {
      if (forward && CanGoForward)
      {
         int idx = LoremIpsum.Ipsum.IndexOf(CurrentParagraph);
         Transition(true);
         CurrentParagraph = LoremIpsum.Ipsum[idx + 1]; 
      }
      else if (!forward && CanGoBackward)
      {
         int idx = LoremIpsum.Ipsum.IndexOf(CurrentParagraph);
         Transition(false);
         CurrentParagraph = LoremIpsum.Ipsum[idx - 1]; 
      }

      RaisePropertyChange("CurrentParagraph");
      RaisePropertyChange("CanGoForward");
      RaisePropertyChange("CanGoBackward");
   }

   public Lorem LoremIpsum { get; set; }

   public Paragraph CurrentParagraph { get; set; }

   protected void RaisePropertyChange(string propertyName)
   {
      PropertyChangedEventHandler handler = PropertyChanged;
      if (handler != null)
      {
         handler(this, new PropertyChangedEventArgs(propertyName));
      }
   }

   public event PropertyChangedEventHandler PropertyChanged;
}

We wire in the typical INotifyPropertyChanged bits, and expose a copy of the main container along with a current paragraph. I have flags to show if/when we can go forward or backward. Finally, two key areas are the method to actually move in either direction, aptly named Move, along with the transition action. Notice we call the transition action, then update the current paragraph. That will factor in later. Also note we wire up a default (empty) action for the transition.

I made a very simple user control to display the text in a given paragraph. It looks like this:

<UserControl x:Class="AnimationExample.ViewParagraph"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
   >
   <Border Background="White" BorderBrush="Black" BorderThickness="5" CornerRadius="15">
      <Grid>
         <TextBlock TextWrapping="Wrap" FontSize="12" TextAlignment="Left" Margin="10"
            HorizontalAlignment="Center" VerticalAlignment="Center"
            Text="{Binding Text}"/>
      </Grid> 
   </Border>
</UserControl>

Great. Now we can plumb out the main page. In the main page I'm going to host two buttons for navigating paragraphs, the paragraph view control, and the model. Here it is in the beginning:

<UserControl x:Class="AnimationExample.MainPage"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
   xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
   xmlns:AnimationExample="clr-namespace:AnimationExample" 
   mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480">
   <UserControl.Resources>
      <AnimationExample:ViewModel x:Key="VM"/> 
   </UserControl.Resources>
   <Grid x:Name="LayoutRoot" DataContext="{StaticResource VM}"> 
   <Grid.ColumnDefinitions>
      <ColumnDefinition Width="200"/>
      <ColumnDefinition Width="200"/>
   </Grid.ColumnDefinitions>
   <Grid.RowDefinitions>
      <RowDefinition Height="400"/>
      <RowDefinition Height="200"/>
   </Grid.RowDefinitions>
   <Button x:Name="Back" Content="<" Grid.Column="0" Grid.Row="1" Click="Back_Click"/>
   <Button x:Name="Forward" Content=">" Grid.Column="1" Grid.Row="1" Click="Forward_Click"/>
   <AnimationExample:ViewParagraph x:Name="View" Grid.Column="0" Grid.ColumnSpan="2" DataContext="{Binding CurrentParagraph}"/> 
   </Grid>
</UserControl>

Because I'm not taking advantage of commanding, I need to do a little wiring for code behind to get the buttons to work. That code behind looks like this:

public partial class MainPage
{
   public ViewModel ViewModel
   {
      get { return LayoutRoot.DataContext as ViewModel; }
   }

   public MainPage()
   {
      InitializeComponent();
      Loaded += MainPage_Loaded;
   }

   void MainPage_Loaded(object sender, RoutedEventArgs e)
   {
      _SetButtons();
      ViewModel.PropertyChanged += (o, args) => _SetButtons();
   }

   private void _SetButtons()
   {
      Back.IsEnabled = ViewModel.CanGoBackward;
      Forward.IsEnabled = ViewModel.CanGoForward; 
   }

   private void Back_Click(object sender, RoutedEventArgs e)
   {
      ViewModel.Move(false);
   }

   private void Forward_Click(object sender, RoutedEventArgs e)
   {
      ViewModel.Move(true);
   }
}

All we do is enable/disable them based on our flags, and hook into property changed so if the flags change, we can update the buttons.

At this point, you can run the project. You'll find a disabled back button, a paragraph, and a forward button. You can click the buttons and navigate between paragraphs and the forward button will be disabled when you hit the end. It all works, and that was a lot of setup, but you'll notice the transition between paragraphs is very boring.

We want to add an animation effect, but what is the best way to do it? Should I create a new view control for each paragraph and swap those in and out? Or is there a better way? I think there is ... and that's the point of this post.

The Animation

First, I'm going to pop in an image as a placeholder. It's important I put this after the main control because the image will overlay it on top. In a grid, I position it in the same row/column and use the same alignment as I do the control itself. We are just putting the control in the main row and spanning two rows. I go ahead and add a plane projection as well so we can do a 3D page flip effect:

<AnimationExample:ViewParagraph x:Name="View" Grid.Column="0" Grid.ColumnSpan="2" DataContext="{Binding CurrentParagraph}">
   <AnimationExample:ViewParagraph.Projection>
      <PlaneProjection CenterOfRotationX="0"/>
   </AnimationExample:ViewParagraph.Projection>
</AnimationExample:ViewParagraph>
<Image x:Name="Clone" Grid.Column="0" Grid.ColumnSpan="2" Visibility="Collapsed">
   <Image.Projection>
      <PlaneProjection CenterOfRotationX="0"/>
   </Image.Projection>
</Image> 

I'm going to rotate on the Y axis so it looks like pages moving toward or away from us. If I used the default projection, it would pivot in the middle. To give it a book effect with the spine to the left, I set the center of rotation X to 0 (it is 0.5 by default) so the Y rotation will "swing" to the left rather than pivot on the center of the image.

Next, I want an easy way to clone the control to the image. The idea is that I clone it to the image, then swap the data. Now I have a snapshot in the image of the old paragraph, while the new paragraph is live. Then I can animate these two for the transition.

Here is my extension method to clone the image:

public static void CloneToImage(this UIElement element, Image image)
{
   var bitmap = new WriteableBitmap(element, new TranslateTransform());
   bitmap.Invalidate();
   image.Source = bitmap;
   image.Width = bitmap.PixelWidth;
   image.Height = bitmap.PixelHeight;
}

Notice it works with any UIElement. We simply initialize a writable bitmap and pass the element and a transformation. Any transformation will do, and because we want an exact duplicate, I simply pass a default translation. The bitmap remains writable and thus is not bindable to an image until we invalidate it, so I do that and then assign to the image and set the width and height. Note this is generic and will work with any control. The action will automatically render that control and all of the children of it to the bitmap.

Now we set up two storyboards, one to flip forward, and one to flip backward. Because these are just mirror images of each other, I'll just show you the first one:

<Storyboard x:Name="PageForward">
   <ObjectAnimationUsingKeyFrames 
Duration="0:0:1"
      Storyboard.TargetName="Clone"
      Storyboard.TargetProperty="(UIElement.Visibility)">
      <DiscreteObjectKeyFrame KeyTime="0:0:0">
         <DiscreteObjectKeyFrame.Value>
            <Visibility>Visible</Visibility>
         </DiscreteObjectKeyFrame.Value>
      </DiscreteObjectKeyFrame>
      <DiscreteObjectKeyFrame KeyTime="0:0:1">
         <DiscreteObjectKeyFrame.Value>
            <Visibility>Collapsed</Visibility>
         </DiscreteObjectKeyFrame.Value>
      </DiscreteObjectKeyFrame>
   </ObjectAnimationUsingKeyFrames>
   <DoubleAnimation 
      Duration="0:0:1"
      Storyboard.TargetName="Clone"
      Storyboard.TargetProperty="(UIElement.Projection).(PlaneProjection.RotationY)"
      From="0" To="90"/>
   <DoubleAnimation 
      Duration="0:0:1"
      Storyboard.TargetName="View"
      Storyboard.TargetProperty="(UIElement.Projection).(PlaneProjection.RotationY)"
From="-90" To="0"/>
</Storyboard>

Normally, I wouldn't stretch an animation out so long, but I wanted to emphasize the effect for this example. We basically "turn on" the image (set it to visible) so it immediately overlays the control. Now we'll have the old on the top and the new on the bottom. Then, we animate the two so the image rotates until it is perpendicular to the "camera" or the viewer and disappears, and then we truly do make it disappear by collapsing it again. The new panel starts out perpendicular and then rotates into view.

The final step is use our extension method to clone the image and actually perform the animation. On the loaded method in the main page, I simply wire in the transition action:

ViewModel.Transition = forward =>
{
   View.CloneToImage(Clone);

   if (forward)
   {
      PageForward.Begin();
   }
   else
   {
      PageBackward.Begin();
   }
};

Here it is in action:

Download the source code for this project

And that's all there is to it!

Jeremy Likness

2 comments:

  1. Jeremy, don't apologize for transitioning(pun intended) into something other than LOB stuff. We all wear multiple hats, and this is a really nice solution to some problems, as well as being just plain cool.

    Thanks!

    ReplyDelete
  2. Animation should be smoother when clicking different or same button.
    Anyway, great article. Thanks!

    ReplyDelete