Introduction
This article discusses how to apply custom animation to an already existing WPF layout by extending classes such as Grid
, StackPanel
, DockPanel
or WrapPanel
. Or indeed any other WPF control that hosts other UIElement
s. An important part of this implementation is that is should be able to extend any existing panel and cope with any child controls of that panel.
By animate in this context, I'm referring to the process of having all the components of a panel move to their positions and change their size over a time.
This is a VB.NET article so all the snippets will be in VB.NET, but for completeness I've included both a C# and a VB.NET solution with roughly equivalent implementations.
Sample Video
I've posted a YouTube video of this project, check it out, the wrapping text thingy looks really cool.
Background
I find that there are two easy ways of making a UI more visually appealing:
- Animate stuff
- Do stuff in 3D
Note that I'm not saying that the UI will have a higher level of usability by doing this, but to me it just looks neater if things are sliding in, flipping over or zooming out. Things look really cool when you manage to combine both points and animate your 3D stuff as I did here.
The animation support in WPF is quite extensive, but for this article I've decided to do custom animation rather than relying on DoubleAnimation
s and StoryBoard
s. The reason for this is I wanted a way to create animated versions of existing panels with as little code as possible.
Using the Code
Pick the language of your choice, download the solution, unzip and open. Each solution has a class library and a WPF test app.
It's all been written using VS2010 Express Edition.
The Approach
So how does one go about adding animation to an existing panel?
Well, the way I approached it was to consider two positions on screen (actually it's three positions and three sizes, but for now I'll stick to only discuss position, and only two of those):
- The current position (or Cp) of the
UIElement
- The position as suggested by whatever panel is being extended (desired position, or Dp)
If these two positions can be established, then the job of animating the UIElement
towards its desired position is simply done by calculating a vector from the current position and then pick another position along that vector.
In maths, the operation of calculating this new position (or Np) is expressed as:
Np = (Dp - Cp) * AnimationSpeed * ElapsedTimeSinceLastFrame
It looks something like this:
By running that calculation several times and every time updating the current position with the just calculated new position, the UIElement
will animate. In my case, I went for a panel local timer, a DispatcherTimer
, and recalculate the Np on every tick.
The additional position I mentioned is an override position that can be used if the position suggested by the extended panel should be ignored for some reason. This could be if the controls should animate of the screen or something like that.
In addition to position, WPF panels also set the size of the child elements, so a current, desired and override version of the sizes also need to be animated.
Attaching Some Properties
So, the math behind the animation is simple enough, but since this implementation has to work with controls that have no knowledge of any desired positions or override positions but only of their actual current position, there needs to be a way of storing these values for each child control of the panel.
To solve this, I decided to create attached properties for all the properties that my approach required, but were not already part of UIElement
. In a class called AnimationBase
, I declare all the attached properties required:
Public Shared ReadOnly CurrentPositionProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("CurrentPosition", _
GetType(Point), _
GetType(AnimationBase), _
New PropertyMetadata(New Point()))
Public Shared ReadOnly CurrentSizeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("CurrentSize", _
GetType(Size), _
GetType(AnimationBase), _
New PropertyMetadata(New Size()))
Public Shared ReadOnly OverrideArrangeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverrideArrange", _
GetType(Boolean), _
GetType(AnimationBase), _
New PropertyMetadata(False))
Public Shared ReadOnly OverridePositionProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverridePosition", _
GetType(Point), _
GetType(AnimationBase), _
New PropertyMetadata(New Point()))
Public Shared ReadOnly OverrideSizeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverrideSize", _
GetType(Size), _
GetType(AnimationBase), _
New PropertyMetadata(New Size()))
The OverrideArrangeProperty
is there to dictate if the animation should strive to go to the desired position or the override position.
Using these properties, the class responsible for the animation can keep track of where the control is now, and where it should be, but not necessarily how to get there. In order to find that out, I went for an implementation where the way that the distance from Cp to Dp is traversed can be swapped out for different implementations.
Replacing the Animation Calculations
In order to be able to replace the logic that calculates how much of the distance between Cp and Dp needs to be traversed in this frame, the AnimationBase
class relies on an interface called IArrangeAnimator
(I picked the name Arrange because the implementation of this project relies on values from the UIElelemt.Arrange
method).
Public Interface IArrangeAnimator
Function Arrange(ByVal elapsedTime As Double, _
ByVal desiredPosition As Point, _
ByVal desiredSize As Size, _
ByVal currentPosition As Point, _
ByVal currentSize As Size) As Rect
End Interface
Essentially, this interface takes a desired position and size along with a current position and size and returns a Rect
indicating where the UIElement
should be after elapsedTime
. In the sample solution, I've only included a single implementation of this interface but it's easy to add your own should you require it.
The IArrangeAnimator
implementation included is called FractionDistanceAnimator
, because it animates with a speed that is set to x pixels per second where x is a fraction of the remaining distance. This means that if the FractionDistanceAnimator
is initialized with a fraction
value of 0.5 and the distance from Cp to Dp is 100 pixels, the speed it'll move with is 50 pixels per second. Obviously, at the next update the distance will be slightly shorter so the next update will run at a slightly lower speed causing the control to ease in to its position.
The implementation of the FractionDistanceAnimator
looks like this:
Namespace Animators
Public Class FractionDistanceAnimator
Implements IArrangeAnimator
Private fraction As Double
Public Sub New(ByVal fraction As Double)
Me.fraction = fraction
End Sub
Public Function Arrange(ByVal elapsedTime As Double, _
ByVal desiredPosition As Point, _
ByVal desiredSize As Size, _
ByVal currentPosition As Point, _
ByVal currentSize As Size) As Rect _
Implements IArrangeAnimator.Arrange
Dim deltaX As Double = _
(desiredPosition.X - currentPosition.X) * fraction
Dim deltaY As Double = _
(desiredPosition.Y - currentPosition.Y) * fraction
Dim deltaW As Double = _
(desiredSize.Width - currentSize.Width) * fraction
Dim deltaH As Double = _
(desiredSize.Height - currentSize.Height) * fraction
Return New Rect(currentPosition.X + deltaX, _
currentPosition.Y + deltaY, _
currentSize.Width + deltaW, _
currentSize.Height + deltaH)
End Function
End Class
End Namespace
I Like to Move it, Move it
To calculate the Rect
returned by IArrangeAnimator.Arrange
, the current and desired position have to be passed in (obviously). This is all handled by the method AnimatorBase.Arrange
which for each child control in the panel performs four steps:
- Get the current position and desired position using the attached properties discussed earlier
- Calculate the
Rect
by calling IArrangeAnimator.Arrange
- Update the current position with the returned
Rect
- Call
UIElement.Arrange
with the returned Rect
There's (to me) surprisingly little code required for all this:
Public Sub Arrange(ByVal elapsedTime As Double, _
ByVal elements As UIElementCollection,
ByVal animator As IArrangeAnimator)
For Each element As UIElement In elements
Dim desiredPosition As Point
Dim currentPosition As Point = _
element.GetValue(AnimationBase.CurrentPositionProperty)
Dim desiredSize As Size
Dim currentSize As Size = _
element.GetValue(AnimationBase.CurrentSizeProperty)
Dim override As Boolean = _
DirectCast(element.GetValue(AnimationBase.OverrideArrangeProperty), Boolean)
If override Then
desiredPosition = _
DirectCast(element.GetValue(AnimationBase.OverridePositionProperty), Point)
desiredSize = _
DirectCast(element.GetValue(AnimationBase.OverrideSizeProperty), Size)
Else
desiredPosition = element.TranslatePoint(New Point(), owner)
desiredSize = element.RenderSize
End If
Dim rect As Rect = _
animator.Arrange(elapsedTime, desiredPosition, _
desiredSize, currentPosition, currentSize)
element.SetValue(AnimationBase.CurrentPositionProperty, rect.TopLeft)
element.SetValue(AnimationBase.CurrentSizeProperty, rect.Size)
element.Arrange(rect)
Next
End Sub
Calling that method on a timer is essentially all that's required to animate any existing panel. And since a timer is always required, the AnimationBase
class provides a helper method for creating it:
Public Function CreateAnimationTimer(ByVal owner As UIElement, _
ByVal animationInterval As TimeSpan)
Me.owner = owner
animationTimer = New DispatcherTimer(DispatcherPriority.Render, _
owner.Dispatcher)
animationTimer.Interval = animationInterval
Return animationTimer
End Function
Private Sub AnimationTick(ByVal sender As Object, ByVal e As EventArgs) _
Handles animationTimer.Tick
owner.InvalidateArrange()
End Sub
Note that the tick handler does not call the animation method directly, but instead just invalidates the current arrange of the animated panel. This in turn causes the panel to recalculate its arrangement of child controls and it is at this point that it is suitable to hook in the animation logic.
Extending Existing Panels
Because almost all the work is done in AnimationBase
and the IArrangeAnimator
, there's very little to do in the extended classes. The little code that is required is always the same for every panel and it looks like this for the Grid
:
Public Class AnimatedGrid
Inherits Grid
Private animationBase As AnimationBase = New AnimationBase()
Private animator As IArrangeAnimator
Private lastArrange As DateTime
Public Sub New()
animationBase.CreateAnimationTimer(Me, TimeSpan.FromSeconds(0.05))
animator = New FractionDistanceAnimator(0.1)
End Sub
Public Sub New(ByVal animator As IArrangeAnimator, _
ByVal animationInterval As TimeSpan)
animationBase.CreateAnimationTimer(Me, animationInterval)
Me.animator = animator
End Sub
Protected Overrides Function ArrangeOverride(ByVal arrangeSize As Size) As Size
Dim size As Size = MyBase.ArrangeOverride(arrangeSize)
animationBase.Arrange(Math.Max(0, _
(DateTime.Now - lastArrange).TotalSeconds), _
Children, animator)
lastArrange = DateTime.Now
Return size
End Function
End Class
The implementation of animated version of other panels are identical except for the class name and the Inherits
statement. This makes it really easy to add animation support.
Points of Interest
If you haven't already, have a look at the video at the top, I think it neatly illustrates how cool a standard WrapPanel
or Grid
can be made to look with just a bit of animation.
History
- 2011-02-03: First version