Tuesday, October 6, 2009

Silverlight Behaviors and Triggers: Storyboard Trigger Example

One of the most powerful benefits of Silverlight is that it uses the DependencyProperty model. Using this model, you can create attached properties to describe reusable behaviors and attach those behaviors to certain elements.

An example of this is firing animations in the UI elements. One criticism of Silverlight has been the lack of support for the range of triggers that Windows Presentation Foundation (WPF) supports. The main "trigger" you can tap into is the Loaded event for a control. This makes it difficult to define triggers for events such as data binding and/or UI events.

It only takes a little bit of magic using the DependencyProperty system, however, to create those triggers yourself.

A behavior is a reusable "action" that can be attached to a control. A trigger is an event that causes something to happen. Imagine having a list that is bound to a list box. Your view model contains the list of top level entities. When a selection item is clicked on, a grid expands (using a Storyboard animation) that contains details for the selected item.

Using Silverlight's advanced databinding features, everything but the animation is straightforward. You can bind the ListBox directly to the list of objects:

...
<ListBox x:Name="ObjectListBox" ItemsSource="{Binding ObjectList}"
   DisplayMemberPath="Name"/>
...

The grid can then automatically databind to the selected object:

...
<Grid DataContext="{Binding ElementName=ObjectListBox,Path=SelectedItem}"/>
...

Now your UI will work like magic - provide your ObjectList, and then click to see the details "magically" appear in the grid. But how do we get the grid to explode? This is where I've seen a lot of attempts to do things like bind collections and triggers to the view model. While I understand the view model is a go-between data and the view, I still think knowing about animations in some cases is too much information for the view model.

I'm not really trying to drive anything from the data. I'm driving a UI behavior with a UI trigger, so why can't I keep all of this cleanly in the XAML, without involving a view model at all? As it turns out, I can.

To understand how to create this, we first need to understand the behavior and the trigger.

The behavior is to kick off a Storyboard. In our case, the storyboard will simply "explode" the grid using a scale transform:

<Grid.Resources>
    <Storyboard x:Name="GridExplode">
        <DoubleAnimation Storyboard.TargetName="TransformSetting" Storyboard.TargetProperty="ScaleX" 
   From="0" To="1.0" Duration="0:0:0.3"/>
        <DoubleAnimation Storyboard.TargetName="TransformSetting" Storyboard.TargetProperty="ScaleY" 
   From="0" To="1.0" Duration="0:0:0.3"/>
    </Storyboard>
</Grid.Resources>
<Grid.RenderTransform>
    <ScaleTransform x:Name="TransformSetting" ScaleX="1.0" ScaleY="1.0"/>
</Grid.RenderTransform>

Now we have a nice behavior, but short of the event trigger provided by Silverlight at load time, we have no easy way to fire it off. Our trigger is the SelectionChanged event on the ListBox. Normally, we would throw the event into the XAML:

...
<ListBox SelectionChanged="ObjectListBox_SelectionChanged"/>
...

Then go into our code behind and kick off the animation:

private void ObjectListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
   GridExplode.Begin();
}

So now that we know the behavior and the trigger, let's try a different way to accomplish it.

I'm going to create a host class for my storyboard triggers and call it, aptly, StoryboardTriggers. The class is static because it exists solely to help me manage my dependency properties. First, we'll want to keep a collection of storyboards that are participating in our new system. We will let the user assign a (hopefully globally unique) key to the Storyboard. This is different from the x:Name because it will be reused throughout the system.

public static class StoryboardTriggers
{
    private static readonly Dictionary<string, Storyboard> _storyboardCollection = new Dictionary<string, Storyboard>();
}

Two steps are required. First, we need to register the storyboard with our collection, so that it is available to manipulate. I like to go ahead and wire in the Completed event to stop the animation so that it can be reused.


public static string GetStoryboardKey(DependencyObject obj)
{
    return obj.GetValue(StoryboardKeyProperty).ToString();
}

public static void SetStoryboardKey(DependencyObject obj, string value)
{
    obj.SetValue(StoryboardKeyProperty, value);
}

public static readonly DependencyProperty StoryboardKeyProperty =
    DependencyProperty.RegisterAttached("StoryboardKey", typeof(string), typeof(StoryboardTriggers),
                                        new PropertyMetadata(null, StoryboardKeyChanged));

public static void StoryboardKeyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
    Storyboard storyboard = obj as Storyboard;
    if (storyboard != null)
    {
        if (args.NewValue != null)
        {
            string key = args.NewValue.ToString();
            if (!_storyboardCollection.ContainsKey(key))
            {
                _storyboardCollection.Add(key, storyboard);
                storyboard.Completed += _StoryboardCompleted;
            }
        }               
    }
}

static void _StoryboardCompleted(object sender, System.EventArgs e)
{
   ((Storyboard)sender).Stop();
}

This is the standard way to declare a new dependency property. The dependency property itself is registered and owned by our static class. Methods are provided to get and set the property. We also tap into the changed event (and we're assuming here that we'll only be attaching the property) and use that event to load the storyboard into our collection.

Now allowing a storyboard to participate in our trigger system is easy, we simply add a reference to the class (I'll call it Behaviors), and then attach the property. Our storyboard now looks like this:

...
<Storyboard x:Name="GridExplode" Behaviors:StoryboardTriggers.StoryboardKey="GridExplodeKey">
...

Note I've given it our "key" of GridExplodKey. Next, let's create a trigger! We want the selection change event to fire the grid. Instead of just writing it for our particular case, we can use the primitive Selector and make the trigger available to any control that exposes the SelectionChanged event. All we want to do is take one of those controls, and set a trigger to the storyboard we want to fire. We do this in our behavior class like this:

public static string GetStoryboardSelectionChangedTrigger(DependencyObject obj)
{
    return obj.GetValue(StoryboardSelectionChangedTriggerProperty).ToString();
}

public static void SetStoryboardSelectionChangedTrigger(DependencyObject obj, string value)
{
    obj.SetValue(StoryboardSelectionChangedTriggerProperty, value);
}

public static readonly DependencyProperty StoryboardSelectionChangedTriggerProperty =
    DependencyProperty.RegisterAttached("StoryboardSelectionChangedTrigger", typeof(string), typeof(StoryboardTriggers),
                                        new PropertyMetadata(null, StoryboardSelectionChangedTriggerChanged));

public static void StoryboardSelectionChangedTriggerChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
    Selector selector = obj as Selector;
    if (selector != null)
    {
        if (args.NewValue != null)
        {
            selector.SelectionChanged += _SelectorSelectionChanged;
        }
    }
}

static void _SelectorSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    string key = GetStoryboardSelectionChangedTrigger((DependencyObject) sender);
    if (_storyboardCollection.ContainsKey(key))
    {
        _storyboardCollection[key].Begin();
    }
}

That's it. When the property is set, we set a handler for the SelectionChanged event. When the selection changed event fires, we get the key from the dependency property, look it up in the master list, and, if it exists, kick off the animation. The property is attached like this:

...
<ListBox x:Name="ServiceList" Behaviors:StoryboardTriggers.StoryboardSelectionChangedTrigger="GridExplodeKey"/>

That's all there is to it! Now we have bound the trigger (selection changed) to the behavior (kick off the animation) and can leave the code-behind and view models completely out of the equation.

For a more full implementation of this, you would want to also handle the event of clearing or detaching the property and remove the event handler from the bound object. (Don't forget your data contract as well ... the methods for attaching, getting, setting, etc should check the parameters and types; I've left that out for brevity here.) You can create other triggers as well and attach those just as easily and even parse multiple values to allow multiple bindings. Once you start thinking in terms of triggers and behaviors within Silverlight, anything truly is possible.

Jeremy Likness

2 comments:

  1. Hi Jeremy,

    This is definitely a solid implementation of a Behaviour. But unfourtnately it's a worst practice.

    If you'd like the UI to respond state changes by changing it's appearence - use the VisualStateManager.
    Seriously, this behaviour is just a ListBox/ListBoxItem Visual State.

    In Silverlight 3+ there's almost absolutely no reason why you should ever have a storyboard outside a Visual State.

    Just my 0.02$.

    -- Justin Angel

    ReplyDelete
  2. It's a good 2 cents and I appreciate you posting it. I do think there is much to be done with visual state manager, but I also find it interesting that triggering a storyboard is a "worst practice" yet Expression Blend ships with a "ControlStoryboardAction" behavior.

    ReplyDelete