In Part 1 we covered the basic history of animating in Xojo using pre-calculated chunks to modify the width of our component. In Part 2 we went a bit further by using linear interpolation(lerp) to calculate our current width at each step of the animation process, and setup for concurrent animation.
In this part we’ll be covering a few different things, chiefly concurrent animations. But that’s not all, as I’ve added a lerpColor function to show you how to animate color changes.
Concurrent Animations
Using self-contained concurrent animations in a UI class is an interesting exercise. You need to have the basic setup for an animation controller complete with a method of adding animation operations to a queue and removing completed animations. Typically, however, if you were building out animations on an application scale, you’d build a class that acted as the animation controller and may raise events for updating the elements as the animation progresses. In this example we’ll just build them straight in to our component because we want this to be entirely independent of the rest of the application architecture.
As this part focuses on concurrent animations, we’re not implementing a keyframe methodology for queuing. We’re using the queue purely to have multiple animations running together.
Defining The Queue
In Part 2 we added the following property to AnimatedCanvas:
animQueue As Dictionary
For this part, we’re going to make a slight modification to that property definition so that it will support multiple animation operations. We’re just going to turn it in to an array:
animQueue() As Dictionary
Dictionary Changes
If you remember from last time we used the following definition for our Dictionary objects:
- Start Time
- Start Width
- End Width
In this part we’re going to further leverage Dictionary’s use of Variant to expand our functionality. While we previously used the following Dictionary structure for our operations:
Dictionary("s" : <StartTimeInMicroseconds>, "sw" : <StartWidth>, "ew" : <EndWidth>)
We’re now defining our animOp Dictionaries as:
Dictionary("op" : <OperationToAnimate>, "l" : <AnimationLength>, "t" : <StartTimeInMicroseconds>, "s" : <StartValue>, "e" : <EndValue>, "o" : <OptionalOlderAnimationOp>)
This allows us to create an animation operation for practically anything we can think of as long as we properly code for it in our animation Timer’s Action event handler method, and give it any length we desire rather than a common time for all operations.
Adding to the Queue
We’re going to add a new method to our AnimatedCanvas class called animQueue_Add
that takes animOp As Dictionary
for its sole parameter. This method will act as a basic out-of-the-loop controller when an animation is to be triggered and determine if this is a new animation operation or one that is already currently in the queue.
In this example I’ve setup for both a Cancel/Start type animation operation (which completely cancels the previous animation and begins a new one with the full animation time), and Continue type animation operation that preserve the original animation time within the new animation time, which is explained in more depth later.
Private Sub animQueue_Add(animOp as Dictionary)
'// This method is part of our animation controller.
' It appends to or modifies our animation queue,
' which runs concurrent rather than consecutive
' operations.
' We first check to make sure that the new animation
' operation dictionary isn't nil.
if IsNull(animOp) then Return
'// We need to find out if we already have an operation
' in the queue for this, so we're going to store our
' index in a variable, which we'll get from our new
' anim_IndexOf method.
dim foundIndex as Integer = animQueue_IndexOf(animOp.Value("op"))
'// If the operation exists, overwrite it. Otherwise,
' append it to our queue so that the timer will
' pick it up on its next Action.
if foundIndex >= 0 then
'// We're also going to implement what I call Time
' Preservation. I'm sure there another name that
' this is more commonly referred to as, but I
' just don't know it. When PreserveTimes = True
' then the animation operation overwrite will
' only use the remaining time of the oldest
' overriden animation operation.
if mPreserveTimes then
'// Grab the oldest op in our override chain so
' we can perform the math necessary to calculate
' an appropriate time remainder.
dim oldOp as Dictionary = animQueue_OldestOp(animQueue(foundIndex))
if not IsNull( oldOp ) then
'// Set our animation length to the remaining value.
' We're getting the assigned length of the animation
' and subtracted the difference between our two
' operation start times.
animOp.Value("l") = animOp.Value("l") - ((animOp.Value("t").DoubleValue - oldOp.Value("t").DoubleValue) / 1000)
'// Set oldOp to our "o" key in case of future overrides.
animOp.Value("o") = oldOp
end if
end if
'// Overwrite the operation.
animQueue(foundIndex) = animOp
else
'// Add the operation to the queue.
animQueue.Append(animOp)
end if
'// Make sure that our animation timer is enabled.
animTimer.Mode = 2
End Sub
As you can see, it’s not an incredibly complex method. We’re just checking our animQueue
property to see an animation currently exists for this operation. If we find it, we replace it. If we don’t we just add it to the tail-end of the queue. The final line, as in the previous version, kicks off our animation timer.
Checking Our Queue
The next new method we’re going to add is our method to check if the animation operation already exists in our queue. It’s a straightforward method where we compare the “op” value of items in the queue to a specified “op” value. If an operation exists for that action, we return the index within the queue, otherwise return -1.
Private Function animQueue_IndexOf(op as String) as Integer
'// This method will check our queue for the
' operation specified in the "op" parameter.
' If it exists, we'll return the index. Otherwise
' we return -1 to denote that this is a new op.
'// We're creating our variable before hand so
' we're not doing it in each iteration of the
' loop, which could slow things down a bit.
dim currDict as Dictionary
'// Likewise, we're creating a variable to store
' the maximum index of our animQueue array. If
' we did this, instead, in the For statement,
' the value would be recalculated each time
' unless something changed that I don't know about.
dim intMax as Integer = animQueue.Ubound
'// We're starting from the end, just in case
' the contents of the queue change in the middle,
' we're less likely to hit an exception.
for intCycle as Integer = intMax DownTo 0
'// We're still going to add exception handling
' just in case.
Try
currDict = animQueue(intCycle)
Catch e as OutOfBoundsException
Continue
End Try
'// If our current dictionary's "op" value
' matches the "op" value we're searching for
' then we'll return the index.
if currDict.Value("op") = op then
Return intCycle
end if
next
'// No matching operation found, so we return -1.
Return -1
End Function
Time Preservation
One new feature we’re implementing is what I call animation time preservation. This shortens subsequent animations of the same operation to honor the original time until one of them is able to complete.
PreserveTimes property
For our animation time preservation, we’re adding a property to the class so we can turn this on and off at will. We’ll make it public and computed so we can add it to the inspector.
Private Property mPreserveTimes as Boolean = True
Public Property PreserveTimes as Boolean
Get
Return mPreserveTimes
End Get
Set
mPreserveTimes = value
End Set
End Property
Getting the Oldest Op
This all really relies on one method, animQueue_OldestOp
which checks the “o” key of the specified animation operation to see if an older operation already exists. If it does, we’ll return it, otherwise return the operation passed in to the method. If you take a look at the changes to animQueue_Add
above, you’ll see how we use this.
'// In this method we're just checking to see if
' there was a pre-existing operation when this
' one was created so that we can use its start
' time to shorten our new animation time.
if IsNull(animOp) then Return Nil
if animOp.HasKey("o") then
dim oldOp as Dictionary = animOp.Value("o")
Return oldOp
end if
Return animOp
Expanding the Height
For this example, to highlight multiple concurrent animations, I’ve also added the ability to animate the height in addition to the width. We’re basically just duplicating the existing properties for our width animation, but applying the values to height. This could be modified to use a Point
or Rect
to achieve the same effect, but this implementation is better for showing how the modifications we’ll be making to the animation Timer’s action actually work by breaking it down independently.
Properties
As with our Width animation, we need to track what our target expanded Height should be as well as the current state of that dimension. For Width we have an expandedWidth
property, so we’ll add an expandedHeight
property to match.
Private Property expandedHeight as Integer
In this version, we’re also renaming our old expanded
property to isExpandedWidth
:
Private Property isExpandedWidth as Boolean = True
And adding a new corresponding property for Height:
Private Property isExpandedHeight as Boolean = True
Finally, we need a property that keeps track of whether we’re currently animating our dimensions so that our subsequent clicks during a color-only animation properly toggle our isExpanded*
properties. We could do this by searching the animOp
array, but just storing it is probably a bit quicker.
Private Property isAnimatingDimensions as Boolean
There are better ways to implement tacking of these states, but for this example I want the verbosity.
MouseUp Changes
Since we’re now animating both Width and Height and implementing our rudimentary animation controller, we need to make some changes to our MouseUp event handler. Primarily we’re duplicating what we already do for Width to account for our new Height animation, and applying the changes to the properties we’ve already discussed. We check to see if we’re currently animating Width or Height before toggling their values to avoid issues in the animation timer’s Action handler.
Sub MouseUp(X As Integer, Y As Integer) Handles MouseUp
'// Just in case we hit some strange scenario where
' the timer is disposed of. Should, theoretically,
' never happen in this case, but better safe than sorry.
if IsNull(animTimer) then Return
'// Now we're going to check for a currently running
' animation. If an animation operation of this type is
' already underway, all we need to do
' is switch the direction the animation is running.
' In a full animation controller, this would be handled
' internally by chaining or cancelling, most likely,
' but this works for our purposes.
if animQueue_IndexOf("width") >= 0 then
isExpandedWidth = not isExpandedWidth
end if
if animQueue_IndexOf("height") >= 0 then
isExpandedHeight = not isExpandedHeight
end if
'// Here we create our dictionary objects containing
' everything our Timer's action event needs to do
' the work. The current time in Microseconds, which
' will be our start time, the start value, and the
' expected result value. This "10" is hard-coded
' just so that we can actually click on the canvas
' again to reverse the animation if it is fully
' collapsed. In most scenarios you wouldn't have
' a hard-coded value like this and you may even be
' triggering the animation from a different element
' altogether, so this value is purely for demonstration
' purposes in this project.
' animQueue_Add is a new method in this part of the
' series, and acts as an animation controller.
animQueue_Add(new Dictionary("op" : "width", "l" : manimTime, "t" : Microseconds, "s" : Width, "e" : If(isExpandedWidth, 10, expandedWidth)))
animQueue_Add(new Dictionary("op" : "height", "l" : manimTime, "t" : Microseconds, "s" : Height, "e" : If(isExpandedHeight, 10, expandedHeight)))
'// Finally, enable the timer to perform the animation.
animTimer.Mode = 2
'// We've added this event, just in case there's
' something you want to trigger at the start of the
' animation.
RaiseEvent BeforeAnimate
End Sub
The New lerpColor
For Part 3 I wanted to add a little something extra before we roll in to Part 4, just to show how we can use linear interpolation for animating any number of different properties of our AnimatedCanvas class. That means we’re going to animate the background color of our class as well!
New Properties
To support our color animation in this specific example, we need a couple of new properties. These properties give us our start and end points for our color animation, and we’re going to add them to the inspector so that they can be changed when editing a Window or ContainerControl view.
- BackgroundColor represents our default background color, which I’ve set to default as red.
- BackgroundColorHover represents the color we want to see when the mouse is over the control, which defaults to blue.
- currentBackgroundColor represents the background color we want to actually apply, and is changed during the animation process.
Public Property BackgroundColor as Color
Get
Return mBackgroundColor
End Get
Set
mBackgroundColor = value
End Set
End Property
Public Property BackgroundColorHover as Color
Get
Return mBackgroundColorHover
End Get
Set
mBackgroundColorHover = value
End Set
End Property
Private Property mBackgroundColor as Color = &cff0000
Private Property mBackgroundColorHover as Color = &c0000ff
Private Property currentBackgroundColor as Color = &cff0000
The Paint Event
Our Paint event only needs one small modification to account for the changes we’re making. We previously used a static red color &cff0000
but we need to switch to using the currentBackgroundColor
property to support the animation.
Sub Paint(g As Graphics, areas() As REALbasic.Rect) Handles Paint
'// Fill the canvas with our current color.
g.ForeColor = currentBackgroundColor
g.FillRect(0, 0, g.Width, g.Height)
End Sub
The MouseEnter Event
Our first step in animating the color for this example is to implement the MouseEnter event within our class. We’re simply creating a new Dictionary for the animation operation, populating that with our values to be used in the animation timer, then calling animQueue_Add
.
Sub MouseEnter() Handles MouseEnter
'// In this version, we're going to animate color change
' based on whether the mouse is over the control.
' So in MouseEnter we start animating from our
' currentBackgroundColor to our BackgroundColorHover.
if me.Enabled then
animQueue_Add(new Dictionary("op" : "color", "l" : 250, "t" : Microseconds, "s" : currentBackgroundColor, "e" : mBackgroundColorHover))
end if
End Sub
You’ll notice that, without the comments, this entire event handler is very short. Part of the goal as I build this functionality is always to make it as easy to implement as possible as I’ll likely use it in a number of different ways for each UI component. Sometimes I have the Dictionary values as parameters of the animQueue_Add
method, but I want to show this in this way for this example.
If I’m implementing animation on an application-wide scale, I’ll typically use a custom class called AnimationOperation
, with the values for the operation stored as properties and passed via the Constructor
method, then a call to add the resulting class instance to the queue for the AnimationController
.
We also assign a static 250ms to color change animations, just to test our dynamic animation times, but you may typically want color change animations to be quicker than other animations in your application just so they stand out less.
The MouseExit Event
MouseExit, like MouseEnter, just creates and adds an animation operation dictionary with the values we want to use. In this case, we want to revert the color back to the default background color.
Sub MouseExit() Handles MouseExit
'// In this version, we're going to animate color change
' based on whether the mouse is over the control.
' So in MouseExit we start animating from our
' currentBackgroundColor to our default BackgroundColor.
if me.Enabled then
animQueue_Add(new Dictionary("op" : "color", "l" : 250, "t" : Microseconds, "s" : currentBackgroundColor, "e" : mBackgroundColor))
end if
End Sub
The lerpColor Method
Now that we have our class setup out of the way, we can create our lerpColor
method. In this example I’m simply applying linear interpolation to the Red, Green, and Blue values of the passed colors, but you could use any other subset of color properties that you want for your desired effect.
Private Function lerpColor(startColor as Color, endColor as Color, alphaValue as Double) as Color
'// This method takes starting and ending color values
' and applies the lerp method for linear interpolation to
' the constituent Red, Green and Blue values to generate
' the new color value based on the animation's elapsed
' time.
' This could be switched from RGB for different effects,
' or you could just use Color.Alpha for making objects
' fade in or out.
dim animStepR as Double = Lerp(startColor.Red, endColor.Red, alphaValue)
dim animStepG as Double = Lerp(startColor.Green, endColor.Green, alphaValue)
dim animStepB as Double = Lerp(startColor.Blue, endColor.Blue, alphaValue)
Return RGB(animStepR, animStepG, animStepB)
End Function
The Timer Action Handler
This is where we’re making the most changes in a single method. Not only are we implementing the animation controller methodology, but we’re also adding two new animation operations — height and color, in addition to the original width — so a lot of this will look different from Parts 1 & 2 in this series.
Private Sub animTimer_Action(sender as Timer)
'// We're going to declare all of our variables outside of the
' loop so that we're not using precious time and resources
' in every iteration of the loop doing it.
dim animOp as Dictionary
dim animOpType as String
dim animStepDouble as Double
dim animStepColor as Color
dim animStartTime, timePercent, animLength as Double
dim animStartDouble, animEndDouble as Double
dim animStartColor, animEndColor, newColor as Color
'// Declare our loop maximum variable. Historically each
' iteration of the loop was known to traverse the array
' to get the maximum value if this wasn't stored in advance.
' I haven't tested this myself recently.
dim intMax as Integer = animQueue.Ubound
'// We start at Ubound and traverse the array in reverse because
' we'll be removing animations from the queue as they complete,
' and this is the best way to avoid OutOfBoundsExceptions.
for intCycle as Integer = animQueue.Ubound DownTo 0
'// Get our current operation dictionary from the queue.
animOp = animQueue(intCycle)
'// Our first step is to make sure we actually have an animation
' to perform.
if IsNull(animOp) then
'// For whatever reason, this entry has been cleared. We'll
' just remove it and continue with the next operation.
animQueue.Remove(intCycle)
Continue
end if
'// Now we'll get our operation values from the dictionary object.
' it is possible to run into KeyNotFoundExceptions here, since
' we're not checking first, but if you do, then you've likely
' made an error when modifying the code. No animQueue Dictionary
' should be created without the same set of values since we're
' making use of variant for the values dependiong on the "op"
' kay's value.
animOpType = animOp.Value("op").StringValue
animStartTime = animOp.Value("t").DoubleValue
animLength = animOp.Value("l").DoubleValue
'// timePercent is used in the lerp function and gives us a basis
' for dividing up the distance we need to cover in to constituent
' pieces for animating. We also use this for cutting out of the
' animation at the appropriate time. This value ranges from
' 0 to 1.0. We subtract our start time from the current time in
' microseconds, then divide by one thousand to convert that in to
' milliseconds. We then divide the result of that by our AnimationTime
' as that's already expressed in milliseconds.
timePercent = (((Microseconds - animStartTime) / 1000) / animLength)
'// In this part of the series, I show how to animate color changes
' as well as dimensions. This method uses RGB, but you could
' alter lerpColor to use any other method you wish.
if animOpType = "color" then
'// Get our animation start and end values.
animStartColor = animOp.Value("s").ColorValue
animEndColor = animOp.Value("e").ColorValue
'// Now we're going to pass the necessary parameters in to
' our new lerpColor function to get the current expected
' color at this stage of the animation.
animStepColor = lerpColor(animStartColor, animEndColor, timePercent)
'// Check to see if this animation operation is complete.
' If so, we remove the operation from the queue and
' move on.
if animStepColor = animEndColor or timePercent >= 1 then
currentBackgroundColor = animEndColor
animQueue.Remove(intCycle)
Continue
end if
'// Assign our lerped color value to the currentBackgroundColor property.
currentBackgroundColor = animStepColor
'// Since we've now moved our color animation to a place where it
' can be animated without changing the dimensions of the control
' we need to tell the control to update when we set a new color value.
Invalidate(False)
else
'// Get our animation start and end values.
animStartDouble = animOp.Value("s").DoubleValue
animEndDouble = animOp.Value("e").DoubleValue
'// Here we pass our start value, end value, and the percentage
' of time passed to our lerp function parameters to
' calculate what the current width should be at this point
' in the total animation time. Note that, in our previous
' version, we used a step value and modified our current width
' but in this version our step value is the entire calculated
' current width.
animStepDouble = Lerp(animStartDouble, animEndDouble, timePercent)
'// If we've reached our ending width or our alotted time has
' passed then we bail and set the values we expect at the
' end of the animation.
select case animOpType
case "width"
'// Check to see if this animation operation is complete.
' If so, we remove the operation from the queue and
' move on.
if animStepDouble = animEndDouble or timePercent >= 1 then
me.Width = animEndDouble ' Set our width to the end result.
isExpandedWidth = not isExpandedWidth ' Toggle the state boolean.
animQueue.Remove(intCycle)
Continue
end if
'// Apply our new width value.
me.Width = animStepDouble
case "height"
'// Check to see if this animation operation is complete.
' If so, we remove the operation from the queue and
' move on.
if animStepDouble = animEndDouble or timePercent >= 1 then
me.Height = animEndDouble
isExpandedHeight = not isExpandedHeight
animQueue.Remove(animQueue.IndexOf(animOp))
Continue
end if
'// Apply our new height value.
me.Height = animStepDouble
end select
end if
next
'// At this stage, we want to see if there are any animation operations
' that have yet to complete. If everything is done, then we disable
' our timer.
if animQueue.Ubound < 0 then
sender.Mode = 0
end if
End Sub
You can see from the code that we’ve moved all of our variable declarations to the top of the method. We do this so that we’re not slowing things down inside the loop by disposing of and redeclaring variables that may be used multiple times. In Part 4 we’ll be adding Pragmas all over the place to make sure our code is even more speedy.
Inside the loop we fill our animOp
property with the current animation operation and verify that it isn’t Nil. If animOp is Nil, then we need to go ahead and remove it from the queue.
Next we populate our animOpType
, animStartTime
, and animLength
variables with their corresponding values, and calculate our current timePercent
for our lerp functions.
The next block is where all of the magic happens. You can see that we’ve just used the same basic methodology from Part 2, but extended it to support animating color and height in addition to width.
One important thing to note is that we’re calling Invalidate(False)
at the end of our conditional if the current operation is a color operation. Without this, our color property’s value would be animated, but the UI would not update to reflect that if this were the only operation currently underway as changes to both Width and Height automatically invalidate the control.
We also do a bit of work on our queue when an animation is complete by removing the current operation when it is complete, and instead of checking the animQueue
property for Nil at the end of the method as we did in Part 2, we now check if there are any remaining operations and halt the timer if the queue is empty.
Final Thoughts
While this method may not be best for every situation or every coding style, I’ve found it to be versatile and dependable. This minimal animation controller is surprisingly robust.
As I mentioned earlier, this animation controller does not make use of keyframes for chaining animations. This could be implemented fairly easily, and while I have done it for a number of projects in the past, I won’t be covering that in this series as you should have some idea of how to incorporate that if you so desire.
In the next part I plan to cover easing to some degree which, like lerp, controls the speed and change in our animation and can add a massive amount of appeal to your user interface.
You can download the example project for Part 3 here.