MinimumTimeVisualStateManager

A little while back I did some Silverlight work that was very focused on providing a fantastic user experience. As part of this work I developed a control that displayed a loading animation:

I then had views that would display this loading control whilst they’re in a Loading state, and then hide it when they change to a Loaded state. That all worked fine. The problem was that sometimes - if the load operation completed quickly enough - the loading animation would briefly flash up on the screen and then disappear. Instead of improving the user experience, it was worsening it.

I could have applied a lengthy duration to the Loading -> Loaded transition, but that would apply indiscriminately to both fast loads, and slow loads. In other words, if the data happened to load slowly, users would have to wait for the slow load, and then again for the slower transition. Hardly compelling from a UX perspective.

What I really wanted to do was impose a minimum amount of time to spend in a particular state. That way I could say “the Loading state must be active for at least 1 second” rather than “the Loading state will be active for a minimum of 1 second”. If the load operation took half a second, the loading animation would still display for a full second. If the load operation took two seconds, the loading animation would only display for that two second period (plus whatever transition time I specified).

Fortunately, the Visual State Manager infrastructure is extensible in that it allows custom a VisualStateManager instance to be associated with a given element. Such a custom manager might do something as simple as logging visual state changes, or something more complex. In my case, I wanted to enforce a minimum time for any state that desires one, which implies an attached property to set that minimum time and some logic to delay state changes when necessary.

Enter, the MinimumTimeVisualStateManager:

publicclassMinimumTimeVisualStateManager:VisualStateManager{publicstaticreadonlyDependencyPropertyMinimumTimeProperty=DependencyProperty.RegisterAttached("MinimumTime",typeof(TimeSpan),typeof(MinimumTimeVisualStateManager),newPropertyMetadata(TimeSpan.Zero));privatestaticreadonlyDependencyPropertyStateChangeMinimumTimeProperty=DependencyProperty.RegisterAttached("StateChangeMinimumTime",typeof(DateTime),typeof(MinimumTimeVisualStateManager),newPropertyMetadata(DateTime.MinValue));publicstaticTimeSpanGetMinimumTime(VisualStatevisualState){if(visualState==null){thrownewArgumentNullException("visualState");}return(TimeSpan)visualState.GetValue(MinimumTimeProperty);}publicstaticvoidSetMinimumTime(VisualStatevisualState,TimeSpanminimumTime){if(visualState==null){thrownewArgumentNullException("visualState");}visualState.SetValue(MinimumTimeProperty,minimumTime);}privatestaticDateTimeGetStateChangeMinimumTime(DependencyObjectdependencyObject){Debug.Assert(dependencyObject!=null);return(DateTime)dependencyObject.GetValue(StateChangeMinimumTimeProperty);}privatestaticvoidSetStateChangeMinimumTime(DependencyObjectdependencyObject,DateTimestateChangeMinimumTime){Debug.Assert(dependencyObject!=null);dependencyObject.SetValue(StateChangeMinimumTimeProperty,stateChangeMinimumTime);}protectedoverrideboolGoToStateCore(FrameworkElementcontrol,FrameworkElementstateGroupsRoot,stringstateName,VisualStateGroupgroup,VisualStatestate,booluseTransitions){Debug.Assert(group!=null&&state!=null&&stateGroupsRoot!=null,"Group, state, or stateGroupsRoot is null for state name '"+stateName+"'. Be sure you've declared the state in the XAML.");varminimumTimeToStateChange=GetStateChangeMinimumTime(stateGroupsRoot);if(DateTime.UtcNow<minimumTimeToStateChange){// can't transition yet so reschedule for latervardispatcherTimer=newDispatcherTimer();dispatcherTimer.Interval=minimumTimeToStateChange-DateTime.UtcNow;dispatcherTimer.Tick+=delegate{dispatcherTimer.Stop();this.DoStateChange(control,stateGroupsRoot,stateName,group,state,useTransitions);};dispatcherTimer.Start();returnfalse;}returnthis.DoStateChange(control,stateGroupsRoot,stateName,group,state,useTransitions);}privateboolDoStateChange(FrameworkElementcontrol,FrameworkElementstateGroupsRoot,stringstateName,VisualStateGroupgroup,VisualStatestate,booluseTransitions){varsucceeded=base.GoToStateCore(control,stateGroupsRoot,stateName,group,state,useTransitions);if(succeeded){SetStateChangeMinimumTime(stateGroupsRoot,DateTime.MinValue);varminimumTimeInState=GetMinimumTime(state);if(minimumTimeInState>TimeSpan.Zero){SetStateChangeMinimumTime(stateGroupsRoot,DateTime.UtcNow+minimumTimeInState);}}returnsucceeded;}}

By way of explanation, the MinimumTimeVisualStateManager uses an attached property called MinimumTime which can be set on VisualState instances. When set, any calls to GoToStateCore will ensure that the required minimum time has been surpassed. If so, the state change succeeds as per normal. If not, a DispatcherTimer is used to switch states at an appropriate point later in time.

With this custom visual state manager, we can impose minimum times for states like this: