The visual state aggregator. What is it?
How many times have you found yourself adding freak methods and commands to your Silverlight projects just to manage some animations? It's a common issue, and I've even built solutions like the IAnimationDelegate to help solve the problem. While it works, one thing always bothered me.
I know it's a ViewModel, so it does do things with the view, but should I really be that concerned with what's going on with the UI? In other words, while it's nice that I can abstract the firing of an animation behind an Action
call and use a command to set it off and then unit test without a real Storyboard
object, it has always felt just a little bit dirty to do it that way.
Download the source code for this post
I also am a big fan of the VisualStateManager
and got to thinking about whether I ever really need a named story board outside of VSM other than programmatic or complex ones that have to be built at runtime. Looking at the current application I'm working with, which uses some very heavy animated transitions, I realized that all of these could be boiled down to the VSM.
Consider this scenario: we have two panels. Panel A is active, Panel B is collapsed and off the screen. These are independent controls that probably don't even share the same view model.
In this case, I'm going to give Panel A a visual state of "Foreground" and Panel B a visual state of "Offscreen". If you're scratching your head about the visual state manager, take a look at Justin Angel's excellent post on the topic. You can create your own groups and states and have both "final" states and transition states.
Quick Tip: Visibility
Probably more people abuse visibility by wiring in delegates or commands that set visibility to visible or collapsed (I'm guilty as well) when it's not needed. Stop thinking in terms of visible, collapsed, etc, and start thinking in terms of states. With the visual state manager, this is what your states might look like:
<VisualStateGroup x:Name="TransitionStates"> <VisualStateGroup.States> <VisualState x:Name="Show"> <Storyboard> <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.00000" Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)"> <DiscreteObjectKeyFrame KeyTime="00:00:00"> <DiscreteObjectKeyFrame.Value> <Visibility>Visible</Visibility> </DiscreteObjectKeyFrame.Value> </DiscreteObjectKeyFrame> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> <VisualState x:Name="Hide"> <Storyboard> <ObjectAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="LayoutRoot" Storyboard.TargetProperty="(UIElement.Visibility)"> <DiscreteObjectKeyFrame KeyTime="00:00:00"> <DiscreteObjectKeyFrame.Value> <Visibility>Collapsed</Visibility> </DiscreteObjectKeyFrame.Value> </DiscreteObjectKeyFrame> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> </VisualStateGroup.States> </VisualStateGroup>
Now you can simply go to a hide or show state, and if you want transitions, you add them to the VSM and voilĂ ! It works ... no need to hard code the transition from collapsed to visible. Now back to our regularly scheduled program ...
When an event happens on Panel A, it goes to the "Background" state and the Panel B goes to the "Onscreen" state. It looks like this:
Then, when we close Panel B, it goes back to "Offscreen" and the Panel A comes back to foreground. By using visual states, we can manipulate the plane projection and scale transform properties as well as the visibility to achieve our results, and use animations.
But here's the rub: how can I fire the event in "A" and then let "B" know it's time to change states?
The most common way I've seen goes something like this:
Put an Action
or delegate of some sort in a view model. In the code behind or using a behavior (more clever than code behind), attach the desired visual state to the action. Make a command object, and while the command does it's thing, go ahead and call the delegate which in turn flips the visual state. For example, my view model might have:
... public Action SwapPanelA { get; set; } ...
And in the code-behind, I can wire it up like this:
... ViewModel.SwapPanelA = () => VisualStateManager.GoToState(this, "Foreground", true); ...
And so on and so on. While this certainly decouples it a bit, it also forces us to change the design of our view model to accommodate the transitions. What if we have four controls participating in the same event? Do we wire up a bunch of references for the view model? Does a control bring in a view model only for the sake of wiring in it's animations?
No, no, no. All wrong (but again, let me elaborate these are things I've tried and worked with in the past, so don't feel bad if you've been doing the same things).
It hit me that I really needed to decouple view actions from business actions. When I click Panel A, the business action might be to call out to a web service to get data to populate in Panel B. I shouldn't care about any transitions.
The view action would be the movement of the panels.
So how do we decouple this? Enter the Visual State Aggregator. What I decided to do was build an aggregator that could respond to published events by flipping view states. This includes a behavior to participate in an event, and a trigger to push the event out. The first piece is a subscription. A subscription contains a weak reference to a control, an event the control is listening for, and the state the control should go to when the event fires (including whether or not to use transitions). Here is VisualStateSubscription
:
public class VisualStateSubscription { private readonly WeakReference _targetControl; private readonly string _state; private readonly bool _useTransitions; private readonly string _event; private Guid _id = Guid.NewGuid(); public VisualStateSubscription(Control control, string vsmEvent, string state, bool useTransitions) { _targetControl = new WeakReference(control); _event = vsmEvent; _state = state; _useTransitions = useTransitions; } public bool IsExpired { get { return _targetControl == null || !_targetControl.IsAlive || _targetControl.Target == null; } } public void RaiseEvent(string eventName) { if (!IsExpired && _event.Equals(eventName)) { var control = _targetControl.Target as Control; VisualStateManager.GoToState(control, _state, _useTransitions); } } public override bool Equals(object obj) { return obj is VisualStateSubscription && ((VisualStateSubscription) obj)._id.Equals(_id); } public override int GetHashCode() { return _id.GetHashCode(); } }
Next comes the aggregator. It contains the list of subscriptions. It will provide a means to subscribe, as well as a means to publish the event. I decided to use the Managed Extensibility Framework (MEF) to wire it in, so it also gets exported:
[Export] public class VisualStateAggregator { private readonly List<VisualStateSubscription> _subscribers = new List<VisualStateSubscription>(); public void AddSubscription(Control control, string eventName, string stateName, bool useTransitions) { _subscribers.Add(new VisualStateSubscription(control, eventName, stateName, useTransitions)); } public void PublishEvent(string eventName) { var expired = new List<VisualStateSubscription>(); foreach(var subscriber in _subscribers) { if (subscriber.IsExpired) { expired.Add(subscriber); } else { subscriber.RaiseEvent(eventName); } } expired.ForEach(s=>_subscribers.Remove(s)); } }
Notice that I take advantage of the event to iterate the subscriptions and remove any that have expired (this means the control went out of scope, and we don't want to force it to hang on with a reference).
Now there is a behavior to subscribe. First, I want to indicate when a control should participate. This behavior can be attached to any FrameworkElement
. Because the visual state manager only operates on controls, I'll walk the visual tree to find the highest containing control for that element. This way, if you have a grid (which is not a control) inside a user control, it will find the user control and use that as the target. If you don't want to use MEF, just give the aggregator a singleton pattern and reference it here instead of using the import.
public class VisualStateSubscriptionBehavior : Behavior<FrameworkElement> { public VisualStateSubscriptionBehavior() { CompositionInitializer.SatisfyImports(this); } [Import] public VisualStateAggregator Aggregator { get; set; } public string EventName { get; set; } public string StateName { get; set; } public bool UseTransitions { get; set; } protected override void OnAttached() { AssociatedObject.Loaded += AssociatedObject_Loaded; } void AssociatedObject_Loaded(object sender, RoutedEventArgs e) { Control control = null; if (AssociatedObject is Control) { control = AssociatedObject as Control; } else { DependencyObject parent = VisualTreeHelper.GetParent(AssociatedObject); while (!(parent is Control) && parent != null) { parent = VisualTreeHelper.GetParent(parent); } if (parent is Control) { control = parent as Control; } } if (control != null) { Aggregator.AddSubscription(control, EventName, StateName, UseTransitions); } } }
Notice we hook into the loaded event so the visual tree is built prior to walking the tree for our hooks.
Of course, this implementation gives us a weak or "magic string" event, you could wire that to an enumeration to make that strongly typed. Let's call the event of swapping out Panel A "ActivatePanelB" and the event of bring it back "DeactivatePanelB". Panel A subscribes like this:
<UserControl> <Grid x:Name="LayoutRoot"> <i:Interaction.Behaviors> <vsm:VisualStateSubscriptionBehavior EventName="ActivatePanelB" StateName="Background" UseTransitions="True"/> <vsm:VisualStateSubscriptionBehavior EventName="DeactivatePanelB" StateName="Foreground" UseTransitions="True"/> </i:Interaction.Behaviors> </Grid> </UserControl>
Then Panel B may subscribe like this:
<UserControl> <Grid x:Name="LayoutRoot"> <i:Interaction.Behaviors> <vsm:VisualStateSubscriptionBehavior EventName="ActivatePanelB" StateName="Onscreen" UseTransitions="True"/> <vsm:VisualStateSubscriptionBehavior EventName="DeactivatePanelB" StateName="Offscreen" UseTransitions="True"/> </i:Interaction.Behaviors> </Grid> </UserControl>
I'm assuming the visual states are wired up with those names.
Now we need something to trigger an event. We use a trigger action for that. The trigger class is very simple, as it simply needs to publish the event. Because we are using a trigger action, we can bind to any routed event in order for the transition to fire, so this would work for a mouseover that had to animate a separate control for example.
public class VisualStateTrigger : TriggerAction<FrameworkElement> { public VisualStateTrigger() { CompositionInitializer.SatisfyImports(this); } [Import] public VisualStateAggregator Aggregator { get; set; } public string EventName { get; set; } protected override void Invoke(object parameter) { Aggregator.PublishEvent(EventName); } }
To trigger this, let's say we have a fancy grid with information and clicking on the grid itself fires the event. In Panel A, we'd wire the event like this:
<Grid> <i:Interaction.Triggers> <i:EventTrigger EventName="MouseLeftButtonUp"> <vsm:VisualStateTrigger EventName="ActivatePanelB"/> </i:EventTrigger> </i:Interaction.Triggers> </Grid>
In Panel B, we might have a close button:
<Button Content="Close"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <vsm:VisualStateTrigger EventName="DeactivatePanelB"/> </i:EventTrigger> </i:Interaction.Triggers> </Button>
And that's it! Without any view models involved, entirely in the UI with the help of our behaviors, we've aggregated the visual state transitions. When you click on the grid, panel A will flip to the "Background" state and animate away, while panel B will flip to the "Onscreen" state and slide in.
Here is an example for you to have fun with:
When you grab the source, you'll find there are absolutely no code-behinds whatsoever other than what is automatically generated. While Panel A and Panel B are completely separate controls that know nothing about each other, the common subscription to a aggregate event coordinates their actions when Panel A is clicked or the close button on Panel B is clicked.
The standalone project for this is included in the download. Enjoy!
P.S. implementing this in my project has been a boon. Was able to eliminate tons of methods and delegates that were there simply to facilitate the VM/View glue. Here's what's nice as well: you can type the events with an enum. If you need to fire an event within your view model, you don't have to find a delegate or grab anything special. Instead, you just import the aggregator and fire your event, like this:
[Import] IAggregator Aggregator { get; set; } private void SomeCode() { Aggregator.RaiseEvent(Events.ActivatePanelB); }
That's it!