After covering the wide range of animations that WPF provides, we can see that many of them were designed to enable us to perform animations that emulate real-world situations, rather than to animate form fields in a standard business application. As such, some of the techniques discussed in this chapter are inappropriate for use in our application framework.
However, this does not mean that we cannot create animations to use in our everyday applications. As long as we remember that less is more when it comes to animations in business applications, we can certainly build simple animations into our application framework. One of the best ways to encapsulate these basic animations in our framework is to write one or more custom-animated panels. Let's look at a simple example of an animated StackPanel:
using System; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Animation; namespace CompanyName.ApplicationName.Views.Panels { public class AnimatedStackPanel : Panel { public static DependencyProperty OrientationProperty = DependencyProperty.Register(nameof(Orientation), typeof(Orientation), typeof(AnimatedStackPanel), new PropertyMetadata(Orientation.Vertical)); public Orientation Orientation { get { return (Orientation)GetValue(OrientationProperty); } set { SetValue(OrientationProperty, value); } } protected override Size MeasureOverride(Size availableSize) { double x = 0, y = 0; foreach (UIElement child in Children) { child.Measure(availableSize); if (Orientation == Orientation.Horizontal) { x += child.DesiredSize.Width; y = Math.Max(y, child.DesiredSize.Height); } else { x = Math.Max(x, child.DesiredSize.Width); y += child.DesiredSize.Height; } } return new Size(x, y); } protected override Size ArrangeOverride(Size finalSize) { Point endPosition = new Point(); foreach (UIElement child in Children) { if (Orientation == Orientation.Horizontal)
{
child.Arrange(new Rect(-child.DesiredSize.Width, 0,
child.DesiredSize.Width, finalSize.Height));
endPosition.X += child.DesiredSize.Width;
}
else
{
child.Arrange(new Rect(0, -child.DesiredSize.Height,
finalSize.Width, child.DesiredSize.Height));
endPosition.Y += child.DesiredSize.Height;
}
AnimatePosition(child, endPosition,
TimeSpan.FromMilliseconds(300)); } return finalSize; } private void AnimatePosition(UIElement child, Point endPosition, TimeSpan animationDuration) { if (Orientation == Orientation. Vertical) GetTranslateTransform(child).BeginAnimation( TranslateTransform.YProperty, new DoubleAnimation(endPosition.Y, animationDuration)); else GetTranslateTransform(child).BeginAnimation( TranslateTransform.XProperty, new DoubleAnimation(endPosition.X, animationDuration)); } private TranslateTransform GetTranslateTransform(UIElement child) { return child.RenderTransform as TranslateTransform ?? AddTranslateTransform(child); } private TranslateTransform AddTranslateTransform(UIElement child) { TranslateTransform translateTransform = new TranslateTransform(); child.RenderTransform = translateTransform; return translateTransform; } } }
As with all custom panels, we just need to provide the implementation for the MeasureOverride and ArrangeOverride methods. However, in our case, we want to recreate the functionality of the original StackPanel control and so we have also declared an Orientation Dependency Property of type System.Windows.Controls.Orientation, with a default value of Vertical.
In the MeasureOverride method, we iterate through each of the panel's children, calling their Measure method, passing in the availableSize input parameter. Note that this sets their DesiredSize property, which will be set to a size of 0,0 until this point.
After calling the Measure method on each child, we are able to use their DesiredSize property values to calculate the total size required to properly display the rendered items, depending on the value of the Orientation property.
If the Orientation property is set to Vertical, we use the Math.Max method to ensure that we keep account of the size of the widest element and if it is set to Horizontal, then we use it to find the height of the tallest element. Once each child has been measured and the overall required size of the panel has been calculated, we return this size value from the MeasureOverride method.
In the ArrangeOverride method, we again iterate through the collection of children, but this time we call the Arrange method on each child, positioning each just outside the bounds of the panel, which will be the starting point of their animations.
If the Orientation property is set to Horizontal, we position the children one child’s width to the left of the origin point and set their height to the height of the panel. If the Orientation property is set to Vertical, we position them one child’s height above the origin point and set their width to the width of the panel.
This has the effect of stretching each item across the height or width of the panel, depending upon the value of the Orientation property, as neatly aligned items with uniform dimensions look tidier and more professional than items with uneven edges. In this way, we can build these kinds of decisions right into our framework controls.
Next, we calculate the desired end position of each child after animation with the endPosition variable and then call the AnimatePosition method, passing in the child, the end position and the duration of the animation. We end the method by returning the unchanged finalSize input parameter.
In the AnimatePosition method, we call the GetTranslateTransform method to get the TranslateTransform object that we will use to move each child across the panel. If the Orientation property is set to Vertical, we animate the TranslateTransform.YProperty property to the value of the endPosition.Y property, otherwise we animate the TranslateTransform.XProperty property to the value of the endPosition.X property.
In order to animate these property values, we use the BeginAnimation method on the UIElement object with the property to be added. There are two overloads of this method, but we are using one that accepts the key of the Dependency Property to animate and the animation object. The other overload enables us to specify the HandoffBehavior to use with the animation.
For our animation, we are using a DoubleAnimation, with a constructor that accepts the To value and the duration of the animation, although there are several other overloads that we could have used, had we needed to specify further properties, such as the From and FillBehavior values.
In order to animate the movement of the items in the panel, we need to ensure that they have a TranslateTransform element applied to the RenderTransform property of the container item of each child. Remember that different ItemsControl classes will use different container items, for example, a ListBox control will use ListBoxItem container elements.
Therefore, if an item does not already have a TranslateTransform element applied, we must add one. Once each element has a TranslateTransform element, we can use its X and Y properties to move the item.
In the GetTranslateTransform method, we simply return the existing TranslateTransform element from the RenderTransform property of each child if one exists, or call the AddTranslateTransform method to return a new one otherwise. In the AddTranslateTransform method, we just initialize a new TranslateTransform element and set it to the RenderTransform property of the child input parameter, before returning it.
We've now created a basic animated panel and with just around seventy lines of code. The developers that use our application framework can now animate the entry of items in any ItemsControl, or any of its derived collection controls, by simply specifying it in a ItemsPanelTemplate as the ItemsPanel value:
xmlns:Panels="clr-namespace:CompanyName.ApplicationName.Views.Panels" ... <ListBox ItemsSource="{Binding Users}"> <ListBox.ItemsPanel> <ItemsPanelTemplate> <Panels:AnimatedStackPanel /> </ItemsPanelTemplate> </ListBox.ItemsPanel> </ListBox>
However, our panel currently only provides one type of animation, albeit in two possible directions, and only works as new items are added. Animating objects' exit is somewhat trickier, because they are normally removed immediately from the panel's Children collection when the Remove method is called on the data bound collection.
In order to accomplish working exit animations, we'll need to implement a number of things. We'll need to update our data Model classes to provide them with new properties to identify which stage of the animation that they're currently in and new events to raise when the current status changes.
We'll need an IAnimatable interface and an Animatable class that provides the implementation for each data Model. Let's first see the interface:
namespace CompanyName.ApplicationName.DataModels.Interfaces { public interface IAnimatable { Animatable Animatable { get; set; } } }
Note that there is already an Animatable class and an IAnimatable interface defined in the System.Windows.Media.Animation namespace. While it can be unwise to create classes and interfaces with the same names as existing ones, for the limited purposes of this book, we will use these names and be mindful to prevent conflicts.
Now let's move on, to see the implementation of our Animatable class:
using System; using CompanyName.ApplicationName.DataModels.Enums; using CompanyName.ApplicationName.DataModels.Interfaces; namespace CompanyName.ApplicationName.DataModels { public class Animatable { private AdditionStatus additionStatus = AdditionStatus.ReadyToAnimate; private RemovalStatus removalStatus = RemovalStatus.None; private TransitionStatus transitionStatus = TransitionStatus.None; private IAnimatable owner; public Animatable(IAnimatable owner) { Owner = owner; } public Animatable() { } public event EventHandler<EventArgs> OnRemovalStatusChanged; public event EventHandler<EventArgs> OnTransitionStatusChanged; public IAnimatable Owner { get { return owner; } set { owner = value; } } public AdditionStatus AdditionStatus { get { return additionStatus; } set { additionStatus = value; } } public TransitionStatus TransitionStatus { get { return transitionStatus; } set { transitionStatus = value; OnTransitionStatusChanged?.Invoke(this, new EventArgs()); } } public RemovalStatus RemovalStatus { get { return removalStatus; } set { removalStatus = value; OnRemovalStatusChanged?.Invoke(this, new EventArgs()); } } } }
This class needs little explanation, other than to note that the OnTransitionStatusChanged and OnRemovalStatusChanged events get raised when the values of the TransitionStatus and RemovalStatus properties are changed respectively and that the class passes itself in as the sender input parameter in each case. Let's see the three new enumeration classes that are used in our Animatable class:
namespace CompanyName.ApplicationName.DataModels.Enums { public enum AdditionStatus { None = -1, ReadyToAnimate = 0, DoNotAnimate = 1, Added = 2 } public enum TransitionStatus { None = -1, ReadyToAnimate = 0, AnimationComplete = 1 } public enum RemovalStatus { None = -1, ReadyToAnimate = 0, ReadyToRemove = 1 } }
We then need to implement this interface in each data Model class that we want to animate:
public class User : ... , IAnimatable { private Animatable animatable; ... public User(Guid id, string name, int age) { Animatable = new Animatable(this); ... } public Animatable Animatable { get { return animatable; } set { animatable = value; } } ... }
The next thing that we need to do is to stop the Remove method from actually removing each item when called. We'll need to update our BaseCollection<T> class, or add a new BaseAnimatableCollection<T> class, so that it triggers the animation instead of removing the item directly. Here is a cut down example showing one way that we might do this:
using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using CompanyName.ApplicationName.DataModels.Enums; using CompanyName.ApplicationName.DataModels.Interfaces; namespace CompanyName.ApplicationName.DataModels.Collections { public class BaseAnimatableCollection<T> : BaseCollection<T> where T : class, IAnimatable, INotifyPropertyChanged, new() { private bool isAnimatable = true; public BaseAnimatableCollection(IEnumerable<T> collection) { foreach (T item in collection) Add(item); } ... public bool IsAnimatable { get { return isAnimatable; } set { isAnimatable = value; } } public new int Count => IsAnimatable ? this.Count(i => i.Animatable.RemovalStatus == RemovalStatus.None) : this.Count(); public new void Add(T item) { item.Animatable.OnRemovalStatusChanged += Item_OnRemovalStatusChanged; item.Animatable.AdditionStatus = AdditionStatus.ReadyToAnimate; base.Add(item); } public new virtual void Add(IEnumerable<T> collection) { foreach (T item in collection) Add(item); } public new virtual void Add(params T[] items) { Add(items as IEnumerable<T>); } public new void Insert(int index, T item) { item.Animatable.OnRemovalStatusChanged += Item_OnRemovalStatusChanged; item.Animatable.AdditionStatus = AdditionStatus.ReadyToAnimate; base.Insert(index, item); } protected override void ClearItems() { foreach (T item in this) item.Animatable.OnRemovalStatusChanged -= Item_OnRemovalStatusChanged; base.ClearItems(); } public new bool Remove(T item) { item.Animatable.RemovalStatus = RemovalStatus.ReadyToAnimate; return true; } public void Item_OnRemovalStatusChanged(object sender, EventArgs e) { Animatable animatable = (Animatable)sender; if (animatable.RemovalStatus == RemovalStatus.ReadyToRemove || (animatable.RemovalStatus == RemovalStatus.ReadyToAnimate && !IsAnimatable)) { base.Remove(animatable.Owner as T); animatable.RemovalStatus = RemovalStatus.None; } } } }
Bear in mind that this is a basic example that could be improved in many ways, such as adding checks for null, enabling addition, removal and insertion capabilities that do not trigger animations and adding other useful properties.
In this class, we start by specifying that the generic T type parameter must implement the IAnimatable interface. As with our other base collection classes, we ensure that all added and inserted items call a new Add method that attaches our animation related handlers. We show an example of this in the constructor, but skip the other constructor declarations to save space.
We then declare an IsAnimatable property that we can use to make this collection work without animation. This property is used in the overridden (or new) Count property, to ensure that items that are due to be removed are not included in the count of the collection's children.
In the new Add method, we attach a reference of our Item_OnRemovalStatusChanged handler to the OnRemovalStatusChanged event of the Animatable object of the item being added. We then set the AdditionStatus property of the Animatable object to the ReadyToAnimate member to signal that the object is ready to begin its entrance animation.
As this base collection is extending another base class, we need to remember to call its Add method, passing in the item, so that it can attach its own handler for the item's PropertyChanged event. The other Add overloads enable multiple items to be added to the collection, but both internally call the first Add method. The Insert method does the same as the first Add method.
The ClearItems method iterates through each item in the collection, detaching the reference to the Item_OnRemovalStatusChanged handler from each before calling the ClearItems method of the base class. As it is, this method could be reserved for removing all items from the collection without animation, but it would be easy to call the Remove method with each item to include animations.
The Remove method in this class enables us to animate the exit of each item; it doesn't actually remove the item from the collection, but instead sets the RemovalStatus property of the item's Animatable object to the ReadyToAnimate member to signal that the object is ready to begin its exit animation. It then returns true from the method to signify successful removal of the item.
Finally, we get to the Item_OnRemovalStatusChanged event handler, which is the next major part in enabling exit animations. In it, we cast the sender input parameter to an instance of our Animatable class. Remember that it passes itself as the sender parameter when raising the event.
We then check whether the RemovalStatus property of the Animatable instance is set to the ReadyToRemove member, or both its RemovalStatus property is set to ReadyToAnimate and the collection is not animatable. If either condition is true, we finally call the Remove method of the base class to actually remove the item from the collection and set the RemovalStatus property to None.
In this way, when the collection is set to be not animatable and the Remove method is called, the item is immediately removed and the Animatable object's RemovalStatus property is set to the None member in the Item_OnRemovalStatusChanged handler. If you remember, the OnRemovalStatusChanged event gets raised when the RemovalStatus property value is changed.
However, we're still missing part of this puzzle. What sets the Animatable object's RemovalStatus property to the ReadyToRemove member to remove each item? We will need to update our animated panel to accomplish this task, and to do this, it will need to maintain a collection of the elements that need to be removed and signal the collection to remove them once their exit animations complete:
private List<UIElement> elementsToBeRemoved = new List<UIElement>();
We can use the Storyboard.Completed event to notify us when the animation is complete and then signal to remove the item at that point, by setting the Animatable object's RemovalStatus property to the ReadyToRemove member. Let's take a look at the required changes to our animated panel. First, we need to add the following using declarations:
using System.Collections.Generic; using CompanyName.ApplicationName.DataModels.Enums; using Animatable = CompanyName.ApplicationName.DataModels.Animatable; using IAnimatable = CompanyName.ApplicationName.DataModels.Interfaces.IAnimatable;
Next, we need to replace the call to the AnimatePosition method from the original ArrangeOverride method with the following line:
BeginAnimations(child, finalSize, endPosition);
We then need to add the following additional methods after the ArrangeOverride method:
private void BeginAnimations(UIElement child, Size finalSize, Point endPosition) { FrameworkElement frameworkChild = (FrameworkElement)child; if (frameworkChild.DataContext is IAnimatable) { Animatable animatable = ((IAnimatable)frameworkChild.DataContext).Animatable; animatable.OnRemovalStatusChanged -= Item_OnRemovalStatusChanged; animatable.OnRemovalStatusChanged += Item_OnRemovalStatusChanged; if (animatable.AdditionStatus == AdditionStatus.DoNotAnimate) { child.Arrange(new Rect(endPosition.X, endPosition.Y, frameworkChild.ActualWidth, frameworkChild.ActualHeight)); } else if (animatable.AdditionStatus == AdditionStatus.ReadyToAnimate) { AnimateEntry(child, endPosition); animatable.AdditionStatus = AdditionStatus.Added; animatable.TransitionStatus = TransitionStatus.ReadyToAnimate; } else if (animatable.RemovalStatus == RemovalStatus.ReadyToAnimate) AnimateExit(child, endPosition, finalSize); else if (animatable.TransitionStatus == TransitionStatus.ReadyToAnimate) AnimateTransition(child, endPosition); } } private void Item_OnRemovalStatusChanged(object sender, EventArgs e) { if (((Animatable)sender).RemovalStatus == RemovalStatus.ReadyToAnimate) InvalidateArrange(); } private void AnimateEntry(UIElement child, Point endPosition) { AnimatePosition(child, endPosition, TimeSpan.FromMilliseconds(300)); } private void AnimateTransition(UIElement child, Point endPosition) { AnimatePosition(child, endPosition, TimeSpan.FromMilliseconds(300)); } private void AnimateExit(UIElement child, Point startPosition, Size finalSize) { SetZIndex(child, 100); Point endPosition = new Point(startPosition.X + finalSize.Width, startPosition.Y); AnimatePosition(child, startPosition, endPosition, TimeSpan.FromMilliseconds(300), RemovalAnimation_Completed); elementsToBeRemoved.Add(child); } private void AnimatePosition(UIElement child, Point startPosition, Point endPosition, TimeSpan animationDuration, EventHandler animationCompletedHandler) { if (startPosition.X != endPosition.X) { DoubleAnimation xAnimation = new DoubleAnimation(startPosition.X, endPosition.X, animationDuration); xAnimation.AccelerationRatio = 1.0; if (animationCompletedHandler != null) xAnimation.Completed += animationCompletedHandler; GetTranslateTransform(child).BeginAnimation( TranslateTransform.XProperty, xAnimation); } if (startPosition.Y != endPosition.Y) { DoubleAnimation yAnimation = new DoubleAnimation(startPosition.Y, endPosition.Y, animationDuration); yAnimation.AccelerationRatio = 1.0; if (startPosition.X == endPosition.X && animationCompletedHandler != null) yAnimation.Completed += animationCompletedHandler; GetTranslateTransform(child).BeginAnimation( TranslateTransform.YProperty, yAnimation); } } private void RemovalAnimation_Completed(object sender, EventArgs e) { for (int index = elementsToBeRemoved.Count - 1; index >= 0; index--) { FrameworkElement frameworkElement = elementsToBeRemoved[index] as FrameworkElement; if (frameworkElement.DataContext is IAnimatable) { ((IAnimatable)frameworkElement.DataContext).Animatable.RemovalStatus = RemovalStatus.ReadyToRemove; elementsToBeRemoved.Remove(frameworkElement); } } }
Let's examine this new code. First, we have the BeginAnimations method, in which we cast the container control to a FrameworkElement, so that we can access its DataContext property. Our data object is accessed from this property and we cast it to an IAnimatable instance, so that we can access the Animatable object via its Animatable property.
We then remove our Item_OnRemovalStatusChanged event handler from the OnRemovalStatusChanged event before re-attaching it, to ensure that only a single handler is attached, regardless of how many times each child passes through this method.
If the AdditionStatus property is set to DoNotAnimate, we arrange the item at its end position immediately and without animation, while if it is set to ReadyToAnimate, we call the AnimateEntry method and then set the AdditionStatus property to Added. Finally, if the RemovalStatus property is set to ReadyToAnimate, we call the AnimateExit method.
In the Item_OnRemovalStatusChanged event handler, we call the panel's InvalidateArrange method if the RemovalStatus property is set to ReadyToAnimate. This is another essential part of the exit animation strategy and it requests the layout system to call the ArrangeOverride method, thereby triggering the starting of the exit animation(s).
Remember that the OnRemovalStatusChanged event gets raised when the value of the RemovalStatus property is changed. Also recall that the RemovalStatus property is set to the ReadyToAnimate member in the Remove method of the BaseAnimatableCollection<T> class. That raises the event and this event handler starts the animations in response.
The AnimateEntry method simply calls the original, unchanged AnimatePosition method from our first animated panel attempt. The AnimateExit method takes an additional startPosition input parameter, which represents the current position of each item within the panel.
We start by setting the Panel.SetZIndex Attached Property to a value of 100 for each child, to ensure that their animated departure is rendered above, or over the top of, the remaining items. We then calculate the end position of the animation using the start position and the size of the panel.
Next, we call an overload of the AnimatePosition method, passing in our child, start and end positions, animation duration and an event handler as parameters. After the child item's position animation has been started, the child is added to the elementsToBeRemoved collection.
In the AnimatePosition method, we first check that our start and end positions are different, before creating and starting our DoubleAnimation objects. If the X values are different and the event handler input parameter is not null, then we attach it to the Completed event of the xAnimation object before starting its animation.
If the Y values are different and the event handler input parameter is not null and the event handler was not already attached to the xAnimation object, then we attach it to the Completed event of the yAnimation object before starting its animation. Note that we only need to attach one handler to this event, because we only have one object to remove from the collection.
Also note that we set the AccelerationRatio property to 1.0 in this overload, so that the item accelerates off screen. However, in a business application framework, we would want to keep our animation properties in sync and so, we would probably set the AccelerationRatio property to 1.0 on the animation objects in the original AnimatePosition method as well.
The last piece of the puzzle is the RemovalAnimation_Completed event handling method. This method gets called when the exit animation has completed and iterates through the elementsToBeRemoved collection. If any element to remove implements the IAnimatable interface, its Animatable object's RemovalStatus property is set to the ReadyToRemove member.
If you remember, this raises the OnRemovalStatusChanged event, which is handled by the Item_OnRemovalStatusChanged event handler in the BaseAnimatableCollection class. In that method, the Animatable object's RemovalStatus property is checked for the ReadyToRemove member and if found, the owning item is actually removed from the collection.
And so, to summarize; the Remove method of the animation collection is called, but instead of removing the item, it sets a property on it, which raises an event that is handled by the animated panel; the panel then starts the exit animation and when completed, it raises an event that is handled by the collection class and results in the item actually being removed from the collection.
While this animated panel is entirely usable as it is, there are many ways that it could be further improved. One important thing that we could do would be to extract all of the properties and animation code from this class and put them into a base AnimatedPanel class. In this way, we could reuse this class when creating other types of animated panel, such as an AnimatedWrapPanel.
We could then further extend the base class by exposing additional animation properties, so that users of our panel could have more control over the animations that it provides. For example, we could declare VerticalContentAlignment and HorizontalContentAlignment properties to dictate how our panel items should be aligned in the panel.
Additionally, we could add EntryAnimationDirection and ExitAnimationDirection properties to specify which direction to animate our panel items as they are added and removed from the panel. We could also enable different types of animation, such as fading or spinning, by animating the Opacity property, or the Angle property of a RotationTransform element.
Furthermore, we could add EntryAnimationDuration and ExitAnimationDuration properties to specify the length of time that each animation should take, rather than hardcoding values directly into our panel. There really is no limit to what functionality that we can provide with our application framework panels, other than the limitations dictated by the end users' computer hardware.