Wednesday, January 19, 2011

Jounce Part 10: Visual State Manager

One of the most common questions I get about Jounce is how to handle transitions, since the ContentControl immediately swaps out content. There are some third-party solutions, such as the TransitioningContentControl from the Silverlight toolkit, but Jounce can also handle this right out of the box.

Take a look at an example:


The secret is through what I believe is the appropriate use of the Visual State Manager and a Jounce helper that was recently added, the Visual State Aggregator. To demonstrate how transitions work, I created a sample project in the Jounce quick start called VSMAggregator. You can download the source to view the example here (simply choose the download link in the upper right for the latest version).

States and Parts

Before we fully dive into why I chose to use the Visual State Manager explicitly within Jounce, you must understand the "states and parts" model present in Silverlight. An excellent series on understanding the model can be found here.

In essence, Silverlight provides for a "lookless" model using parts and states. Lookless allows you to define a control that has a very specific purpose, yet allow the user quite a bit of room in customizing how it looks and behaves. This is because you don't define the full UI for the control. Instead, you define the parts of the control (the surface of a button, the "thumb" on a scroll bar, the background of a slider) and allow the user to template them. Then, you define the possible states of the control and give the user the ability to respond to a state by changing colors or animating properties.

This is a very powerful system and allows much of the design work to be completely offloaded to the design team while the developer can focus on development. As a developer, the main thing you need to know is that you can go into a state. Controls can have different groups that represent different aspects of states, but we'll keep it simple for now. Assume we have a group called "NavigationStates" and we know we can either be in a "HideState" or a "ShowState."

A Question of Control

The Visual State Manager (VSM) provides an attached property called VisualStateGroups for defining states. When you are creating a custom control, there are precise areas where you can define the states for the parts of your template. Likewise, if you are customizing a templated control, the template supplies all of the states and you simply have the task of deciding what they do.

What happens in a UserControl that doesn't technically have parts? This is where it gets interesting. You can only change the state of an element that inherits from Control. However, defining your visual states at the UserControl level may have unexpected results because you are not able to reference the elements that are contained within the root visual. Fortunately, if you define the visual states groups in the root visual for the UserControl, even though the attached property exists at the level of an element that may not inherit from Control, those states will still be available to the parent control.

This means that typically you will define the visual states for a UserControl in whatever you choose as the "LayoutRoot" which in most cases will be a Grid. The only caveat is that when you programmatically manipulate states, you must climb the visual tree to the nearest Control element to choose as a target for your state changes. More on that in a bit.

States are Storyboards

States are storyboards, pure and simple. As long as you think of states that way, you should have no problem grasping the significance. If you aren't already aware of the hierarchy used by Silverlight to evaluate dependency properties, I suggest you hurry up and read about Dependency Property Value Precedence. Basically, the VSM uses storyboards to coerce dependency properties into a different value.

It can sometimes get confusing when you are defining a visual state because you have two parts to work with: a state (required) and a transition (optional). These are actually two different storyboards that operate against the target control. What's even more confusing is that a "state" is supposed to be the fixed value of the control but you can supply a duration and animate a state! What?

Don't do it! Just because you can doesn't mean you should.

States and Transitions

To make your life easier, I'd like to suggest what I consider some best practices. To make visual states behave the way you want and expect, follow these two guidelines:

One: States are zero-duration storyboards that represent exactly how your control should look when it is in that state.

In the example, our RedView defines three states. A "hidden state," a "shown state" and a special state it goes into when a slide-in is chosen. To begin with, we simply define exactly how we want it to look in the given state.

<VisualStateManager.VisualStateGroups>
    <VisualStateGroup x:Name="NavigationStates">
        <VisualStateGroup.States>
            <VisualState x:Name="Default"/>
            <VisualState x:Name="ShowState">
                <Storyboard Duration="0:0:0">
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>
                                <Visibility>Visible</Visibility>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.IsHitTestVisible)">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>true</DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </VisualState>
            <VisualState x:Name="HideState">
                <Storyboard Duration="0:0:0">
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>
                                <Visibility>Collapsed</Visibility>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </VisualState>
            <VisualState x:Name="GreenState">
                <Storyboard Duration="0:0:0">
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>
                                <Visibility>Visible</Visibility>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                    <DoubleAnimation 
                        Duration="0:0:0"
                        Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Projection).(PlaneProjection.RotationY)"
                        From="0" To="-80"/>
                    <DoubleAnimation 
                        Duration="0:0:0"
                        Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
                        From="0" To="-205"/>                            
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.IsHitTestVisible)">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                            <DiscreteObjectKeyFrame.Value>false</DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </VisualState>
        </VisualStateGroup.States>
     </VisualStateGroup>
   </VisualStateManager.VisualStateGroups>

The collapsed state only concerns itself with one thing: being collapsed. The visible state ensures it is both visible and hit test visible so the surface is clickable. In the special state with the green slide-in, the control is visible and rotated in a plane projection. It also is not hit test visible, so the "sideways buttons" can't be clicked. Notice that all of the storyboards have a duration of 0. This is the precise set of values we want imposed on our visual when the control is in different states.

Two: Transitions can animate whatever you like, but they should always end up in the same state as the state definition being transitioned to.

Transitions provide quite a bit of flexibility. What's important to understand when dealing with states, however, is that there are two ways to programmatically drive states in Silverlight. You can go to the state with transitions, or without. In all cases, Silverlight will fire the state animation. In the case of transitions, it will fire the transition animation, and then immediately fire the state animation after the transition storyboard completes. The VSM leaves the state storyboard playing after the transition is complete, but will stop the transition storyboard.

If the states and transitions don't match, your control will be left in an unknown state - especially if code has the flexibility to use transitions (or not). Further, if a property is left in a different state by the state animation than the transition animation, only one storyboard can "win" so you always want the animations to conclude on the properties you desire.

The easiest way to make a transition work is to simply specify the states you are transitioning from and to, and provide a generated duration. The visual state manager will modify the storyboard in the state definition to last for the duration, and smoothly animate the properties.

If you want to manipulate other factors, such as a projection, or have key frames, then you must use the transitions. If you are using transitions that don't overlap the state, those animations will stop after the transition is complete and there will be no conflict. Animations that overlap the existing properties in the state storyboard should end with the same values to avoid any conflicts.

Take a look at the transitions on our RedView:

<VisualStateGroup.Transitions>
    <VisualTransition To="GreenState" GeneratedDuration="0:0:1">
        <Storyboard Duration="0:0:1">
            <DoubleAnimation 
                Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Projection).(PlaneProjection.RotationY)"
                From="0" To="-80"/>
            <DoubleAnimation 
                Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
                From="0" To="-205"/>                            
        </Storyboard>
    </VisualTransition>
    <VisualTransition From="GreenState" To="ShowState" GeneratedDuration="0:0:1">
        <Storyboard Duration="0:0:1">
            <DoubleAnimation 
                Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Projection).(PlaneProjection.RotationY)"
                From="-80" To="-0"/>
            <DoubleAnimation 
                Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
                From="-205" To="0"/>
        </Storyboard>
    </VisualTransition>
    <VisualTransition From="ShowState" To="HideState" GeneratedDuration="0:0:1">                        
        <Storyboard Duration="0:0:1">
            <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0">
                    <DiscreteObjectKeyFrame.Value>
                        <Visibility>Visible</Visibility>
                    </DiscreteObjectKeyFrame.Value>
                </DiscreteObjectKeyFrame>
            </ObjectAnimationUsingKeyFrames>
            <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Opacity)"
                                From="1" To="0"/>
        </Storyboard>
    </VisualTransition>
    <VisualTransition From="HideState" To="ShowState" GeneratedDuration="0:0:1">                       
        <Storyboard Duration="0:0:1">
            <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0">
                    <DiscreteObjectKeyFrame.Value>
                        <Visibility>Visible</Visibility>
                    </DiscreteObjectKeyFrame.Value>
                </DiscreteObjectKeyFrame>
            </ObjectAnimationUsingKeyFrames>
            <DoubleAnimation Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Opacity)"
                                From="0" To="1"/>
        </Storyboard>
    </VisualTransition>
</VisualStateGroup.Transitions>

Notice that the animations start by setting the control to visible. This is because of the order I mentioned earlier: the transitions fire, then the states. Therefore, if you want to animate a panel into view, you need to make it visible first in case it was collapsed before, or your animation simply won't be visible. The animations may perform different effects but they should leave the control in the "target" state we are looking for. The end result is that changing states with or without transitions should end up with the same result - again, you can easily mess this up so it's important to keep in mind.

Firing States in Jounce

Jounce provides several mechanisms to fire visual states. One of the most powerful is through the Visual State Aggregator (VSA). The VSA allows you to coordinate states completely in XAML. In a nutshell, you create a "transaction" or an "event" and give it a name. In any control that should respond to the event, you add a subscription and indicate what state the control should go to when the event is raised, and whether or not to use transitions. Here are the subscriptions in the RedView:

<Interactivity:Interaction.Behaviors>
    <Views:VisualStateSubscriptionBehavior EventName="NavigateGreen" StateName="GreenState" UseTransitions="True"/>
    <Views:VisualStateSubscriptionBehavior EventName="NavigateRed" StateName="ShowState" UseTransitions="True"/>
    <Views:VisualStateSubscriptionBehavior EventName="NavigateBlue" StateName="HideState" UseTransitions="True"/>
</Interactivity:Interaction.Behaviors>

As you can see, when the "NavigateGreen" event is raised, the control goes to the "GreenState." Likewise, a "NavigateRed" (the view itself) goes to the "ShowState" and a "NavigateBlue" (a different view) goes to the "HideState."

To fire events, you can either publish them programmatically or use a trigger. In our RedView we have two triggers. One button for the green slide-in simply publishes the event. That's because the GreenView is static text and doesn't require a view model. Another button for the blue view fires both an event to transition and a navigation event to wire in the view model:

<Button HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="10" Content=" GREEN ">
    <Interactivity:Interaction.Triggers>
        <Interactivity:EventTrigger EventName="Click">
            <Views:VisualStateAggregatorTrigger EventName="NavigateGreen"/>
        </Interactivity:EventTrigger>
    </Interactivity:Interaction.Triggers>
</Button>
<Button HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="10" Content=" BLUE ">
    <Interactivity:Interaction.Triggers>
        <Interactivity:EventTrigger EventName="Click">
            <Views:VisualStateAggregatorTrigger EventName="NavigateBlue"/>
            <Services:NavigationTrigger Target="BlueView"/>
        </Interactivity:EventTrigger>
    </Interactivity:Interaction.Triggers>
</Button>

To demonstrate the difference between manipulating views and actually navigating, the RedView updates a date on the screen. You'll notice when you navigate to the BlueView and back, the date changes. When you navigate to the green slide-in and back, the date does not. This is because the green slide-in is simply a visual state transition, while the BlueView is both a transition and an actual navigation event.

Visual States and View Models in Jounce

Obviously, having multiple screens or dynamically loaded ones will prevent you from wiring every possibility in the XAML. When Jounce wires a view to a view model, it sets a delegate that allows you to raise state changes (and automatically marshalls them to the UI threads). This allows view models to handle states. In fact, the generic IViewModel interface supports the visual state methods. Typically, a controller will handle navigation through wizards and selections, and raise the navigation events as described in previous Jounce posts. The view model can then transition to the correct state when the _Active method is called. Because view models may have multiple views, the view tag is passed in _Activate and the state transition can be targetted to that view. This allows Jounce to manipulate states from the view model only knowing what the name of the state and optionally the tag of the view is, without having a direct reference to the view or any view artifacts.

The BlueView takes advantage of this and instead of using behaviors and triggers, manages visual states 100% programmatically. Here is the full code for the BlueViewModel:

[ExportAsViewModel(Globals.VIEWMODEL_BLUE)]
public class BlueViewModel : BaseViewModel, IBlueViewModel  
{
    public BlueViewModel()
    {
        Dates = new ObservableCollection<string>();
        RedCommand = new ActionCommand<object>(o=>_RedAction());     
    }
       
    public ObservableCollection<string> Dates { get; private set; }

    public IActionCommand RedCommand { get; private set; }

    [Import]
    public VisualStateAggregator VsmAggregator { get; set; }

    private void _RedAction()
    {
        GoToVisualState("HideState", true);
        VsmAggregator.PublishEvent("NavigateRed");
        EventAggregator.Publish(Globals.VIEW_RED.AsViewNavigationArgs());
    }

    public override void _Initialize()
    {
        Dates.Add(string.Format("Initialized: {0}", DateTime.Now));
        GoToVisualState("HideState", false);
    }

    public override void _Activate(string viewName)
    {
        Dates.Add(string.Format("Activated: {0}", DateTime.Now));
        GoToVisualState("ShowState", true);
    }        
}

Notice that when the view model is initialized, it goes into a default hidden state and does not use transitions. Initialize is always called before activate. Once activated, it logs the activation and goes to the "ShowState." The button for the RedView fires a command that changes the state of the BlueView, publishes the event for the red navigation that the visual state aggregator listens to, and finally navigates to the RedView.

Manipulating Visual States Directly

In Silverlight 4 you can tap into events and transitions and inspect the various states. You can even get to the storyboards associated with states. Because states are actual storyboards, you can do things like attach behaviors or programmatically access the storyboards and manipulate them to do things like accommodate the current screen resolution or compute complex animations.

Whenever a transition happens to the RedView, a message is displayed. This message is handled in code-behind that hooks into the VSM and latches onto the state transition events. Take a look at the code:

public RedView()
{
    InitializeComponent();
    Loaded += RedView_Loaded;            
}

void RedView_Loaded(object sender, RoutedEventArgs e)
{
    var groups = VisualStateManager.GetVisualStateGroups(LayoutRoot);
    foreach(var group in groups.Cast<VisualStateGroup>().Where(g=>g.Name.Equals("NavigationStates")))
    {
        group.CurrentStateChanged += GroupCurrentStateChanged;                
    }
}

static void GroupCurrentStateChanged(object sender, VisualStateChangedEventArgs e)
{
    JounceHelper.ExecuteOnUI(() => MessageBox.Show(string.Format("Transition {0}=>{1}", e.OldState.Name, e.NewState.Name)));
}

The code grabs the group called NavigationStates, hooks into the state change event, and then uses a message box to publish the transition. To see how to dive even deeper and access storyboards, read Page Brooks' excellent article Finding a Storyboard in the Visual state Manager.

Hosting the Views

The final step is hosting the views. Instead of using a ContentControl, the easiest way to allow views to transition in and out without including a third-party control is use an ItemsControl and override the panel to a Grid, like this:

<ItemsControl HorizontalAlignment="Center" HorizontalContentAlignment="Stretch"
                Width="480" Height="300"
                VerticalAlignment="Center" VerticalContentAlignment="Stretch"
                Regions:ExportAsRegion.RegionName="MainRegion">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

Notice this is a region that views can be routed to. It allows the views to stack on top of each other, and then transition in and out based on the visual states. In Jounce by default, all views are tracked and simply activate or deactivate. The main difference between a ContentControl and an ItemsControl is that in one, the view is completely removed, and in the other, it is collapsed. So what does this look like? The views will remain in the items control but in their collapsed state have no impact on the visual tree. I've used this method with dozens of views and have not noticed any significant, negative impact as the "hidden" views are truly collapsed. Obviously if you are using transient views, then a different strategy such as a TransitioningContentControl should be used instead.

You can download the source to view the example here (simply choose the download link in the upper right for the latest version).

Jeremy Likness