Playing a series of storyboards in canon using WPF

May 31, 2007

When developing user interfaces in WPF, there might be times in an application where you want to play a number of storyboards in canon, that is, in succession, one after the other. One such case would be if you have different views in a single window application, with separate storyboards to open and close each view. When the user raises the command to open a particular view, you want to first play the storyboard that closes the current view, followed by playing the storyboard that opens the requested view. This way you don’t need separate storyboards to cater for the move from one view to another, or extra steps in your storyboards that first close the other views.

The usual way to start storyboards in WPF is with a BeginStoryboard action inside a property or event trigger. This can be declared in XAML so that when a particular trigger is fired a storyboard, or number of storyboards, is started. Unfortunately, there is no way to instruct WPF to play the storyboards in succession, they will simply be started at the same time.

The following is a class you can use in your form’s code to play a series of storyboards in succession.

using System; using System.Collections.Generic; using System.Text; using System.Windows; using System.Windows.Media.Animation; using System.Diagnostics; namespace seemyrisk { /// <summary>  /// Plays a series of storyboards in a canon sequence, one after the other  /// </summary>  class CanonStoryboards { private List<Storyboard> _storyboards = new List<Storyboard>(); private int _count = -1; /// <summary>  /// The FrameworkElement that contains the storyboards  /// </summary>  private FrameworkElement _containingObject; public FrameworkElement ContainingObject { get { return _containingObject; } set { _containingObject = value; } } /// <summary>  /// Initializes a new instance of the CanonStoryboards class  /// </summary>  public CanonStoryboards() { } /// <summary>  /// Initializes a new instance of the CanonStoryboards class and  /// sets the containing object used when starting the storyboards  /// </summary>  /// <param name="containingObject">The FrameworkElement that contains  /// the storyboards</param>  public CanonStoryboards(FrameworkElement containingObject) : this() { _containingObject = containingObject; } /// <summary>  /// Adds a storyboard to the canon sequence.  /// Storyboards are played in the order they are added.  /// </summary>  /// <param name="storyboard">The storyboard to add</param>  public void AddStoryboard(Storyboard storyboard) { if (storyboard == null) throw new ArgumentNullException("storyboard"); _storyboards.Add(storyboard); } /// <summary>  /// Begins the canon sequence of storyboards  /// </summary>  public void Begin() { if (_containingObject == null) throw new InvalidOperationException( "ContainingObject must be set before Begin can be called"); if (_storyboards.Count == 0) throw new InvalidOperationException( "Storyboards must be added before Begin can be called"); // start the first storyboard  BeginStoryboard(_storyboards[0]); } /// <summary>  /// Handler for storyboards' Completed event  /// </summary>  /// <param name="sender">The sender</param>  /// <param name="e">The event args</param>  void Storyboard_Completed(object sender, EventArgs e) { Debug.WriteLine("CanonStoryboards: storyboard completed"); // get the next storyboard in the series  Storyboard nextStoryboard = GetNextStoryboard(); if (nextStoryboard != null) { BeginStoryboard(nextStoryboard); } else { Debug.WriteLine("CanonStoryboards: all storyboards completed"); } } /// <summary>  /// Begins a storyboard in the canon sequence  /// </summary>  /// <param name="storyboard">The storyboard to begin</param>  void BeginStoryboard(Storyboard storyboard) { // wire up hanlder to completed event  storyboard.Completed += new EventHandler(Storyboard_Completed); _count++; // increment storyboard counter  storyboard.Begin(_containingObject); Debug.WriteLine("CanonStoryboards: storyboard begun, " + storyboard.Name); } /// <summary>  /// Gets the next storyboard in the canon sequence  /// </summary>  /// <returns>The next storyboard in the sequence,  /// null if no other storyboards to play</returns>  Storyboard GetNextStoryboard() { if (_count >= 0 && _storyboards.Count > _count + 1) { return _storyboards[_count + 1]; } return null; } } }

You can use the class in your code like this. In this example the storyboards are being started in the handler for a command execution. The storyboard in the window’s resource dictionary with the key “closeAbout” will play first, followed by the “openHome” storyboard:

void OnLoadHomeCommand(object sender, ExecutedRoutedEventArgs e) { // Start the show home storyboard CanonStoryboards cs = new CanonStoryboards(this); cs.AddStoryboard(((Storyboard)Resources["closeAbout"])); cs.AddStoryboard(((Storyboard)Resources["openHome"])); cs.Begin(); }

You might also find it useful to be able to play a number of storyboards in parallel and then play a final storyboard:

void OnLoadHomeCommand(object sender, ExecutedRoutedEventArgs e) { // Start the show home storyboard CanonStoryboards cs = new CanonStoryboards(this); cs.AddStoryboard(((Storyboard)Resources["closeAbout"])); cs.AddStoryboard(((Storyboard)Resources["openHome"])); ((Storyboard)Resources["closeFaq"]).Begin(this); cs.Begin(); }

Note that the last sample will result in the “openHome” storyboard being played after the “closeAbout” storyboard has completed, with no regard to the completion time of the “closeFaq” storyboard. As long as the closeAbout storybard is as least as long as the closeFaq storyboard, the openHome storyboard will play after the closeFaq storyboard. You could extend the functionality of the CanonStoryboards class to allow the grouping of storyboards into parallel and serial sets so that you could have more control over the sequence that the storyboards play in, but I’ll leave that as an exercise to the reader.


Writing a custom shape for WPF

May 24, 2007
The problem

The project I’m currently working on involves the development of a prototype user interface to an existing application using WPF. One of the visual effects I wanted was to have two elements joined together by a “tail”, the purpose of which was to help visually tie the input from the controls in one box to the result displayed in the other. Something like this:

WPF custom shape wireframe

The result box was going to change size depending on the input so the tail had to change too. Due to its shape, it couldn’t be achieved using a Rectangle and a simple skew transform, so I had to choose another approach. My first effort involved using a Polygon and binding its Points property to properties of the two elements and creating a custom Converter to generate the appropriate points collection. This approach worked well, until it came to tracking the position of the elements. Due to the way that WPF data binding works, the values of the properties I was binding to, to determine the element positions, weren’t being updated until *after* the converter was called. This meant that when one of the elements moved, the tail was one step behind. This lead me to investigate developing a custom shape.

So what’s involved in writing a custom shape?

Surprisingly not much! You simply need to inherit from the abstract Shape class and provide an override for the getter of the DefiningGeometry property. This returns the Geometry object that defines the shape of your shape. Easy!


using System; using System.Collections.Generic; using System.Text; using System.Windows.Shapes; using System.Windows.Media; namespace CustomWpfShape { class ElementJoiner : Shape { protected override Geometry DefiningGeometry { get { } } } }

In order to draw the shape we’re going to need to have references to the two elements we want to “join” with the tail, so we’ll add two dependency properties so that we can bind to those elements:

public FrameworkElement SourceElement { get { return (FrameworkElement)GetValue(SourceElementProperty); } set { SetValue(SourceElementProperty, value); } } public FrameworkElement TargetElement { get { return (FrameworkElement)GetValue(TargetElementProperty); } set { SetValue(TargetElementProperty, value); } } public static readonly DependencyProperty SourceElementProperty = DependencyProperty.Register("SourceElement", typeof(FrameworkElement), typeof(ElementJoiner), new UIPropertyMetadata(new PropertyChangedCallback(WireUpSource))); public static readonly DependencyProperty TargetElementProperty = DependencyProperty.Register("TargetElement", typeof(FrameworkElement), typeof(ElementJoiner), new UIPropertyMetadata(new PropertyChangedCallback(WireUpTarget)));

You’ll need to add a using statement for the System.Windows namespace too:

using System.Windows;

You might have noticed the reference to two handler methods passed into the DependencyProperty.Register method: WireUpSource and WireUpTarget. These methods will get called whenever the dependency properties are set, and we’re going to use them to add event handlers to some key events of the elements we’re joining, namely those events that indicate changes to the elements’ layout:

private static void WireUpSource(DependencyObject d, DependencyPropertyChangedEventArgs e) { Debug.WriteLine("ElementJoiner dependency property changed"); ElementJoiner ej = d as ElementJoiner; FrameworkElement el = e.NewValue as FrameworkElement; if (ej != null && el != null) { el.SizeChanged += new SizeChangedEventHandler(ej.FrameworkElement_SizeChanged); el.LayoutUpdated += new EventHandler(ej.SourceFrameworkElement_LayoutUpdated); } } private static void WireUpTarget(DependencyObject d, DependencyPropertyChangedEventArgs e) { Debug.WriteLine("ElementJoiner dependency property changed"); ElementJoiner ej = d as ElementJoiner; FrameworkElement el = e.NewValue as FrameworkElement; if (ej != null && el != null) { el.SizeChanged += new SizeChangedEventHandler(ej.FrameworkElement_SizeChanged); el.LayoutUpdated += new EventHandler(ej.TargetFrameworkElement_LayoutUpdated); } }

As you can see, we’ve added handlers to the SizeChanged and LayoutUpdated events of the source and target elements. Note that the handlers are instance methods on our custom shape and that we have to cast the passed in DependencyObject parameter to the type of our custom shape before we wire them up.

The handlers simply need to inform WPF that our custom shape needs to be redrawn. Doing this will cause the overridden DefiningGeometry property to be evaluated again and our shape will be updated. This is easily done by calling the InvalidateVisual method:

private Point _lastSourceOffset; private Point _lastTargetOffset; private void SourceFrameworkElement_LayoutUpdated(object sender, EventArgs e) { Debug.WriteLine("ElementJoiner source element layout updated - " + DateTime.Now.Ticks); Visual anc = SourceElement.FindCommonVisualAncestor(TargetElement) as Visual; GeneralTransform sourceOffsetTransform = SourceElement.TransformToAncestor(anc); Point sourceOffset = sourceOffsetTransform.Transform(new Point(0, 0)); if (!sourceOffset.Equals(_lastSourceOffset)) { // Only redraw if position has changed _lastSourceOffset = sourceOffset; this.InvalidateVisual(); } } private void TargetFrameworkElement_LayoutUpdated(object sender, EventArgs e) { Debug.WriteLine("ElementJoiner target layout layout updated - " + DateTime.Now.Ticks); Visual anc = SourceElement.FindCommonVisualAncestor(TargetElement) as Visual; GeneralTransform targetOffsetTransform = TargetElement.TransformToAncestor(anc); Point targetOffset = targetOffsetTransform.Transform(new Point(0, 0)); if (!targetOffset.Equals(_lastTargetOffset)) { // Only redraw if position has changed _lastTargetOffset = targetOffset; this.InvalidateVisual(); } } private void FrameworkElement_SizeChanged(object sender, SizeChangedEventArgs e) { Debug.WriteLine("ElementJoiner source or target size changed - instance"); this.InvalidateVisual(); }

Notice that the handlers for the LayoutUpdated event are only calling InvalidateVisual if the calculated offset from the common ancestor of the source and target elements has changed. The LayoutUpdated event gets raised very frequently and not doing this results in an immediately obvious performance issue. The FrameworkElement_SizeChanged handler could be modified to do the same in order to save a few cycles too.

Now that our elements are all wired up the only thing left to do is implement the overridden property DefiningGeometry:

protected override Geometry DefiningGeometry { get { if (SourceElement == null || TargetElement == null) { return new PathGeometry(); } Visual anc = SourceElement.FindCommonVisualAncestor(TargetElement) as Visual; if (anc == null) throw new InvalidOperationException(); GeneralTransform sourceOffsetTransform = SourceElement.TransformToAncestor(anc); GeneralTransform targetOffsetTransform = TargetElement.TransformToAncestor(anc); Point sourceOffset = sourceOffsetTransform.Transform(new Point(0, 0)); Point targetOffset = targetOffsetTransform.Transform(new Point(0, 0)); // Point order is: SourceElement top right corner, TargetElement top left corner, // TargetElement bottom left corner, SourceElement bottom right corner Point point1 = new Point(SourceElement.ActualWidth + sourceOffset.X, sourceOffset.Y); Point point2 = new Point(targetOffset.X, targetOffset.Y); Point point3 = new Point(targetOffset.X, TargetElement.ActualHeight + targetOffset.Y); Point point4 = new Point(SourceElement.ActualWidth + sourceOffset.X, SourceElement.ActualHeight + sourceOffset.Y); List<PathSegment> segments = new List<PathSegment>(4); segments.Add(new LineSegment(point2, false)); segments.Add(new LineSegment(point3, false)); segments.Add(new LineSegment(point4, false)); segments.Add(new LineSegment(point1, false)); List<PathFigure> figures = new List<PathFigure>(1); PathFigure pf = new PathFigure(point1, segments, true); figures.Add(pf); Geometry g = new PathGeometry(figures, FillRule.EvenOdd, null); return g; } }

Basically what’s happening here is the offset position of the source and target elements to their common ancestor is calculated and then used to create four points that will define the tail, with the first point being the top right hand corner of the source element. We then work our way around the shape until we have all four points.

WPF custom shape point order

These points are used to build a list of PathSegments which in turn define a PathFigure (in our case only one PathFigure is required but your shape could contain as many PathFigures as you want). The PathFigure is then used to create a PathGeometry, which in turn defines our custom shape’s shape! And because we’re notified when the source or target elements’ layout or size is changed via the event handlers, our tail stays in sync between the two elements when they change.

Note that if you apply a render transform to the source or target elements, the shape will *not* change to suit, as render transforms do not affect the actual layout of the element, only their appearance.

Also note that due to the way that this custom shape’s geometry is determined, via the common ancestor of the source and target elements, any instance of the shape must be a direct child of that common ancestor in your element hierarchy. So if the common ancestor of the source and target elements is a grid, then the custom shape must be placed directly in that grid, in the first row and column and be spanned across all the grid’s rows and columns.

As a bonus you don’t have to do anything special to support the normal formatting and layout properties for your custom shape, the base class does that all for you. You can paint your shape with any brush you want, stroke the border, apply a transform, animate its properties and it displays in Expression Blend properly too.

That’s all it takes to create a custom shape for WPF. Let your imagine run wild and and see what wild and wacky shapes you can come up with.

You can download a sample solution of the ElementJoiner shape described in this article, including the failed custom converter approach, from here. You can run an XBAP version of the sample from here.

Next article I’ll show how I extended the shape with some more properties to aid in creating a particular animation effect.