Canceling WPF Animations Made Simple

Not long ago, I had the opportunity to debut the new Apocalypse module for my Omnified Vision overlay application, live on stream. It was something I’d been working on for a little while, its importance amplified by that fact that it was to be the final piece of the Omnified experience (for now).

Naturally, given how I pine for (and even sometimes get!) flawless performances, I subjected the Apocalypse Vision module to a little bit of playtesting prior to the stream. This meant running around in game, getting smacked by enemies, dying lots, and marveling at the new visualization magic that is Vision.

Many issues were corrected and fixed, however there was one glaring issue: whenever there were many, many new Apocalypse events within a few seconds, the screen would be absolutely cluttered with a whole batch of new items, all running their (eye-grabbing) “reveal” notifications.

So, in order to alleviate this, the idea was simple: upon a new event being added to the collection of Apocalypse events, any preexisting and in progress animations would have their playback canceled. Annoyingly, doing this wasn’t as easy as I thought it’d be. In this article, we’ll go over just how I ended up doing it.

Visualizing the Problem

The Apocalypse Vision module’s root view is the ApocalypseView type, and it occupies a particular “anchor point” on Vision‘s overlay. Typically, this will be the top-center or bottom-center of the screen.

The ApocalypseView is a collection-based view that is composed of an ItemsControl bound to a collection of ApocalypseEventViewModel children. Each child’s data context is then resolved to a particular view dependent on the nature of the Apocalypse event itself.

Each child view is decorated by the ApocalypseView in a ContentControl that is essentially composed of two Border controls surrounding the actual child view content; it is these two Border controls where the animations we desire to cancel are being ran.

There are two animation storyboards at play: one for the outer border, and one for the inner border. The first storyboard, which we can refer to as the RevealBounce, causes the event to “bounce” into view, and also causes the otherwise transparent background of the event to be filled with color temporarily.

The second storyboard, which we can refer to as the RevealShimmer, begins playing a few seconds after RevealBounce, and causes the background of the event to “shimmer” slightly, further drawing attention to this newly arriving event.

While these animations are very neat on their own, they end up blocking too much of the game’s screen when there are many of them appearing at once, as can be seen below:

Shows a growing collection of child controls whose animations are starting to clutter up the screen. We must cancel them!
The “new item” reveal animation becomes a bit too much (in terms of screen real estate) when multiple events are happening per second.

As Vision is meant to be used as an overlay on a game, all the controls used in its various modules are mostly transparent, with the text designed to be readable on top of a variety of backgrounds. This is so we can still see everything we need to in the game we’re playing, and having too many of these just-described animations running goes against this goal.

How To Cancel an Animation?

There are a number of ways we can cancel an in progress Storyboard, most of which are fairly inconvenient when trying to rock a purely MVVM approach, where there’s almost no logic in the code-behind of our XAML views.

Let’s go over these different ways.

Trigger Actions

We typically begin playback of an animation using the BeginStoryboard action, triggered by either an event or a change of a bound property.

Likewise, we can also abort the playback of an animation using another trigger action, such as the StopStoryboard and SkipStoryboardToFill actions, which we can use like so:

<StopStoryboard BeginStoryboardName="RevealBounceAction"/>

This approach would be purely XAML-based, so it is rather palatable to my tastes at least; however, it does require a proper source of triggering, such as a RoutedEvent. In this particular case, this requirement essentially precludes this approach from being a valid one to take, as the communication regarding a new child item must occur from a parent to its children, a direction of communication that routed events simply do not support.

Individual Animation Methods

The next way of canceling the playback of an animation involves invoking methods on the individual animations found in a storyboard themselves. Doing it this way would obviously require us to use C# instead of XAML.

Animations, such as the DoubleAnimation type, have a BeginAnimation method which accepts two parameters: the DependencyProperty being animated, and the Storyboard to use as the animation timeline.

Calling this method while providing the DependencyProperty and null for the timeline will essentially cancel the animation.

doubleAnimation.BeginAnimation(UIElement.OpacityProperty, null);

This particular way of canceling an animation is painful for a number of reasons. First of all, we’d need to do it for every single animation that might exist in a particular storyboard, each of which is probably going to be problematic to retrieve in code-behind using a generalized method.

Secondly, we would need to provide the actual DependencyProperty that’s being animated. That’s another requirement that would end up being painful to do, especially if we mean to do so using a generalized solution.

Storyboard Methods

A final way of canceling animations is to invoke the same methods on a Storyboard instance that the trigger actions, described in the first approach above, invoke.

Using a Storyboard instance, we can call something like SkipToFill, which only would require a target DependencyObject to be passed as a parameter.

storyboard.SkipToFill(targetObject);

Using this approach will probably be the easiest if we wish to concoct a generalized solution for canceling in progress animations. All it requires are instances of the Storyboard and target DependencyObject in question.

And, because it looks like we’re going to be using an approach requiring C# code, the best way to implement it, while maintaining our pure use of XAML, is with a custom attached behavior.

A Behavior to Cancel Animations

Attached behaviors are simply attached properties that influence the way the target DependencyObject instances they are attached to act. While you don’t always need a set of foundational objects to create your own behaviors, I will be using the Bad Echo Presentation framework, part of the Bad Echo technologies that I’ve been publishing.

This behavior will be attached to the DependencyObject being animated in XAML. It then requires two more things for its successful operation: 1) the storyboard resource to exert control over, and 2) a means of receiving a notification to go ahead and cancel the animation.

Having to provide more than one piece of data to an attached behavior or property is an innately difficult thing to do in XAML. This is due to how these behaviors are typically applied to an element, which is by including them using attribute syntax, setting the attached property exposed by the behavior to a single binding or value.

For example, we could attach a CancelableAnimationBehavior to an object while providing the Storyboard to control like so:

<Border badEcho:CancelableAnimationBehavior.Storyboard="{StaticResource NameOfStoryboard}"/>

But how do we also provide the notification object? You simply cannot using attribute syntax; rather, such things become much easier to do if you use property element syntax. This requires, however, a “properties” type that can hold the required pieces of data.

This properties type cannot just be a simple class, however, as it most likely will require the ability to support bindings and therefore an inheritance context. Additionally, the behavior class has to also be crafted in a special way that can support an attachable child element.

Luckily for us, or for me rather, the Bad Echo Presentation framework provides a number of powerful foundational behavior-related objects we can use to craft the types that we need.

CancelableAnimationState

Let’s start off with the “properties” type that will hold the data the behavior will require in order to do its work.

We’ll name this as the CancelableAnimationState, and it will be an AttachableComponent<DependencyObject>. This is a type exposed by the Bad Echo Presentation framework, and one that essentially makes it a Freezable with support for having an inheritance context.

This type will expose two attached properties: 1) one for receiving a Storyboard instance, and 2) one for receiving the notification object, which will be an instance of the Mediator type, an object exposed by the Bad Echo Presentation framework that allows for simple message passing between disparate UI elements in an WPF application.

Upon receiving a request to cancel its animations via the Mediator, the CancelableAnimationState will invoke an event that the behavior is listening to, in order for that behavior to go ahead and do its work.

Here is the code for the CancelableAnimationState:

/// <summary>
/// Provides attachable animation state data to a <see cref="CancelableAnimationBehavior"/>
/// instance controlling it.
/// </summary>
/// <remarks>
/// This is meant to be provided to a <see cref="CancelableAnimationBehavior"/> instance using
/// property element syntax in order to provide both messaging support as well as a means to
/// specify which storyboard we aim to make cancelable.
/// </remarks>
public sealed class CancelableAnimationState : AttachableComponent<DependencyObject>
{
    /// <summary>
    /// Identifies the <see cref="Mediator"/> dependency property.
    /// </summary>
    public static readonly DependencyProperty MediatorProperty =
        DependencyProperty.Register(nameof(Mediator),
                                    typeof(Mediator),
                                    typeof(CancelableAnimationState),
                                    new PropertyMetadata(OnMediatorChanged));
    /// <summary>
    /// Identifies the <see cref="Storyboard"/> dependency property.
    /// </summary>
    public static readonly DependencyProperty StoryboardProperty =
        DependencyProperty.Register(nameof(Storyboard),
                                    typeof(Storyboard),
                                    typeof(CancelableAnimationState));
    /// <summary>
    /// Occurs when a request to cancel the playback of <see cref="Storyboard"/> has been made.
    /// </summary>
    public event EventHandler? AnimationCanceling;

    /// <summary>
    /// Gets or sets the storyboard whose playback on the dependency object this component is
    /// attached to will be made cancelable.
    /// </summary>
    public Storyboard? Storyboard
    {
        get => (Storyboard) GetValue(StoryboardProperty); 
        set => SetValue(StoryboardProperty, value);
    }

    /// <summary>
    /// Gets or sets the mediator used to receive animation cancellation requests.
    /// </summary>
    public Mediator? Mediator
    {
        get => (Mediator) GetValue(MediatorProperty);
        set => SetValue(MediatorProperty, value);
    }
    
    /// <inheritdoc/>
    protected override Freezable CreateInstanceCore()
        => new CancelableAnimationState();

    private static void OnMediatorChanged(
        DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var animationState = (CancelableAnimationState) sender;

        if (e.OldValue is Mediator oldMediator) 
            animationState.UnregisterMediator(oldMediator);

        if (e.NewValue is Mediator newMediator)
            animationState.RegisterMediator(newMediator);
    }
    
    private void RegisterMediator(Mediator mediator)
        => mediator.Register(SystemMessages.CancelAnimationsRequested, 
                             MediateCancelAnimationsRequest);

    private void UnregisterMediator(Mediator mediator)
        => mediator.Unregister(SystemMessages.CancelAnimationsRequested, 
                               MediateCancelAnimationsRequest);

    private void MediateCancelAnimationsRequest()
    { 
        AnimationCanceling?.Invoke(TargetObject, EventArgs.Empty);
    }
}

CancelableAnimationBehavior

Let’s next go over the actual attached behavior that will do the animation canceling.

This behavior, which we’ll name as the CancelableAnimationBehavior will derive from CompoundBehavior<FrameworkElement, CancelableAnimationState>, which is another type made available by the Bad Echo Presentation framework. It allows for us to easily create a behavior that requires an AttachableComponent<T> to be able to be assigned to it using property element syntax.

Once this behavior is told to cancel animations via the AnimationCanceling event, it will do so by calling SkipToFill on the attached data’s Storyboard property. This method essentially fast forwards the animation to its end, and will leave us with a UI element at the end of its animation timeline (which, in this case, will appear as if it was never animated at all, as the animated properties return to their original values at the end).

Here is the code for CancelableAnimationBehavior:

/// <summary>
/// Provides a behavior that, when attached to a target dependency object, allows for the
/// immediate cancellation of an animation running on it. 
/// </summary>
public sealed class CancelableAnimationBehavior 
    : CompoundBehavior<FrameworkElement, CancelableAnimationState>
{
    /// <summary>
    /// Identifies the attached property that gets or sets this behavior's
    /// <see cref="CancelableAnimationState"/> data, which ultimately specifies the storyboard
    /// this behavior will be canceling if requested to do so.
    /// </summary>
    public static readonly DependencyProperty StateProperty 
        = RegisterAttachment();

    /// <summary>
    /// Gets the value of the <see cref="StateProperty"/> attached property for a given
    /// <see cref="DependencyObject"/>.
    /// </summary>
    /// <param name="source">The dependency object from which the property value is read.</param>
    /// <returns>
    /// The <see cref="CancelableAnimationState"/> associated with <c>source</c>.
    /// </returns>
    public static CancelableAnimationState GetState(DependencyObject source)
    {
        Require.NotNull(source, nameof(source));

        return GetAttachment(source, StateProperty);
    }

    /// <summary>
    /// Sets the value of the <see cref="StateProperty"/> attached property on a given
    /// <see cref="DependencyObject"/>.
    /// </summary>
    /// <param name="source">The dependency object to which the property is written.</param>
    /// <param name="value">The <see cref="CancelableAnimationState"/> to set.</param>
    public static void SetState(DependencyObject source, CancelableAnimationState value)
    {
        Require.NotNull(source, nameof(source));

        source.SetValue(StateProperty, value);
    }

    /// <inheritdoc/>
    protected override void OnValueAssociated(
        FrameworkElement targetObject, CancelableAnimationState newValue)
    {
        base.OnValueAssociated(targetObject, newValue);

        newValue.AnimationCanceling += HandleAnimationCanceling;
    }

    /// <inheritdoc/>
    protected override void OnValueDisassociated(
        FrameworkElement targetObject, CancelableAnimationState oldValue)
    {
        base.OnValueDisassociated(targetObject, oldValue);

        oldValue.AnimationCanceling -= HandleAnimationCanceling;
    }

    /// <inheritdoc/>
    protected override Freezable CreateInstanceCore()
        => new CancelableAnimationBehavior();

    private static DependencyProperty RegisterAttachment()
    {
        var behavior = new CancelableAnimationBehavior();

        return DependencyProperty.RegisterAttached(
            NameOf.ReadAccessorEnabledDependencyPropertyName(() => StateProperty),
            typeof(CancelableAnimationState),
            typeof(CancelableAnimationBehavior),
            behavior.DefaultMetadata);
    }

    private static void HandleAnimationCanceling(object? sender, EventArgs e)
    {
        if (sender == null)
            return;

        var targetObject = (FrameworkElement)sender;

        CancelableAnimationState state = GetAttachment(targetObject, StateProperty);
        
        state.Storyboard?.SkipToFill(targetObject);
    }
}

The State attached property is shadowed, something required in order for the attached data to function correctly.

The Cancel Animations Request and Behavior Implementation

The message to cancel animations needs to be sent whenever a new item is being added to the collection of view models. The perfect place for this, in our case, is the view model that acts as the data context for the collection-based view: the ApocalypseViewModel.

The Mediator used for communication will be exposed by this view model, and its use will occur within the OnChildrenChanged override when a new item is being detected:

/// <summary>
/// Gets the mediator for messages to be sent or received through.
/// </summary>
public Mediator Mediator 
{ get; } = new();
.
.
.
/// <inheritdoc/>
protected override void OnChildrenChanged(CollectionPropertyChangedEventArgs e)
{
    base.OnChildrenChanged(e);

    if (e.Action is CollectionPropertyChangedAction.Add)
        Mediator.Broadcast(SystemMessages.CancelAnimationsRequested);
}

We can then make use of our new behavior by applying it directly on the Border controls whose animations need to be canceled. Here is the entire code for the ContentControl style’s template used to decorate the child views — you will be able observe our new behavior being applied using property element syntax:

<ControlTemplate TargetType="{x:Type ContentControl}">
  <Border Margin="0,5,0,5">
      <Border.Resources>
          <badEcho:ArithmeticConverter x:Key="DirectionalConverter"
                                       Operand="35"
                                       Operation="Multiplication"
                                       />
          <Storyboard x:Key="RevealBounce">
              <DoubleAnimation Storyboard.TargetProperty="(Border.Background).(GradientBrush.Opacity)" 
                               BeginTime="00:00:05"
                               From="1"
                               To="0"
                               />
              <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.Y)"
                               Duration="00:00:01"
                               From="{Binding DataContext.DirectionalCoefficient, 
                                              Converter={StaticResource DirectionalConverter},
                                              RelativeSource={RelativeSource AncestorType={x:Type badEcho:View}}}"
                               To="0">
                  <DoubleAnimation.EasingFunction>
                      <BounceEase Bounces="3"
                                  EasingMode="EaseOut"
                                  Bounciness="2"
                                  />
                  </DoubleAnimation.EasingFunction>
              </DoubleAnimation>
          </Storyboard>
      </Border.Resources>
      <Border.RenderTransform>
          <TranslateTransform/>
      </Border.RenderTransform>
      <Border.Background>
          <LinearGradientBrush StartPoint="0,0.5" EndPoint="1,0.5" >
              <GradientStop Color="#FFA4A2A3"/>
              <GradientStop Color="#FFD2D2D2" Offset="1"/>
          </LinearGradientBrush>
      </Border.Background>
      <Border.Triggers>
          <EventTrigger RoutedEvent="Loaded">
              <!--The BeginStoryboard action must have a name, as giving it name will result in the runtime making the
                  resulting storyboard controllable, a capability this view requires.-->
              <BeginStoryboard x:Name="RevealBounceAction" Storyboard="{StaticResource RevealBounce}"/>
          </EventTrigger>
      </Border.Triggers>
      <badEcho:CancelableAnimationBehavior.State>
          <badEcho:CancelableAnimationState Storyboard="{StaticResource RevealBounce}"
                                            Mediator="{Binding   DataContext.Mediator, 
                                                                 RelativeSource={RelativeSource AncestorType={x:Type badEcho:View}}}"
                                            />
      </badEcho:CancelableAnimationBehavior.State>
      <Border>
          <Border.Resources>
              <Storyboard x:Key="RevealShimmer">
                  <DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.Background).(GradientBrush.GradientStops)[0].(GradientStop.Offset)"
                                                 BeginTime="00:00:02">
                      <EasingDoubleKeyFrame KeyTime="00:00:03" 
                                            Value="0.8"
                                            />
                  </DoubleAnimationUsingKeyFrames>
                  <DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.Background).(GradientBrush.GradientStops)[1].(GradientStop.Offset)"
                                                 BeginTime="00:00:02">
                      <EasingDoubleKeyFrame KeyTime="00:00:03" Value="0.9"/>
                  </DoubleAnimationUsingKeyFrames>
                  <DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.Background).(GradientBrush.GradientStops)[2].(GradientStop.Offset)"
                                                 BeginTime="00:00:02">
                      <EasingDoubleKeyFrame KeyTime="00:00:03" Value="1"/>
                  </DoubleAnimationUsingKeyFrames>
                  <DoubleAnimation Storyboard.TargetProperty="(Border.Background).(GradientBrush.Opacity)"
                                   BeginTime="00:00:05"
                                   From="1"
                                   To="0"
                                   />
              </Storyboard>
          </Border.Resources>
          <Border.Background>
              <LinearGradientBrush EndPoint="1.7,1" StartPoint="-0.5,0">
                  <GradientStop Color="#00FFFFFF"/>
                  <GradientStop Color="#FFFFFFFF" Offset="0.1"/>
                  <GradientStop Color="#00FFFFFF" Offset="0.2"/>
              </LinearGradientBrush>
          </Border.Background>
          <Border.Triggers>
              <EventTrigger RoutedEvent="Loaded">
                  <!--The BeginStoryboard action must have a name, as giving it name will result in the runtime making the
                      resulting storyboard controllable, a capability this view requires.-->
                  <BeginStoryboard x:Name="RevealShimmerAction" Storyboard="{StaticResource RevealShimmer}"/>
              </EventTrigger>
          </Border.Triggers>
          <badEcho:CancelableAnimationBehavior.State>
              <badEcho:CancelableAnimationState Storyboard="{StaticResource RevealShimmer}" 
                                                Mediator="{Binding   DataContext.Mediator, 
                                                                     RelativeSource={RelativeSource AncestorType={x:Type badEcho:View}}}"
                                                />
          </badEcho:CancelableAnimationBehavior.State>
          <ContentPresenter/>
      </Border>
  </Border>
</ControlTemplate>

The full code can be found here if you wish to see it.

The Results

The Apocalypse module for Vision now successfully cancels all extraneous animation playback upon a new item being added:

Shows existing animations being canceled when new events get added.
Running animations now get canceled whenever a new item is added. Beautiful.

A wonderful result, implemented in an elegant fashion!

I hope you found this article illuminating. Until next time!