It is time again for more javascript animation! We have talked about animation in javascript multiple times before, starting with Sliding Panels, and then advancing to more complex capabilities in Generic Animation. This time we are taking the ideas in the previous Generic Animation tutorial and expanding on them in pretty much every way possible.
So what were the problems with the previous Generic Animation functions? Well, first off, there was no "control". Once you set an animation in motion, there was no way to control it - i.e., stop it, reset it, and so on. Second, it was not exactly exact. The completion time that was given to the function was almost a 'minimum value', and in fact, the slower the computer, the longer it would take for an animation to complete. Well, wait no more! The answers to both those problems, and more, are in the code below.
What we have below should be familiar to anyone who looked at the first Generic Animation tutorial. You can set values in all the text boxes, and when you press "Go!", you can watch the red box move and resize to the values you have given. This example is powered by the Generic Animation V2 code, but it doesn't really show off any of the new features. What it does show off is that the animation will always complete in the amount of time specified (within a few milliseconds, at least). If you would like to take a look at the other capabilities allowed by the new code, you should take a look at the example at the bottom of the tutorial.
| New X Pos: | New Y Pos: | ||
| New Width: | New Height: | ||
| Time (Millisec) | |||
Ok, now that you are done playing around, lets jump straight into the code. The new generic animation code is object oriented, and below is the structure of the object:
{
if(typeof(element) == "string")
element = document.getElementById(element);
var frames = null;
var timeoutID = -1;
var running = 0;
var currentFI = 0;
var currentData = null;
var lastTick = -1;
var callback = null;
var prevDir = 0;
this.AddFrame = function(frame){}
this.SetCallback = function(cb){}
this.ClearFrames = function(){}
this.ResetToStart = function(){}
this.ResetToEnd = function(){}
this.Stop = function(){}
this.RunForward = function(){}
this.RunBackward = function(){}
function animate(){}
}
Ok, so lets attack this piece by piece. First off, we can create an AnimationObject by handing it an element or an element ID (which it will resolve to an element). This is the element that will be animated. The next couple lines define private state variables for this animation object - and as we come across them in code, I will explain what they do. Next we come across the function AddFrame. I guess the first thing to do here is explain what a frame is.
Frame here refers to an AnimationFrame, another javascript object. It represents, well, a frame of animation. It holds a position and size, and an amount of time in which to get to that new position and size. The object code looks like the following:
{
this.Left = left;
this.Top = top;
this.Width = width;
this.Height = height;
this.Time = time;
this.Copy = function(frame)
{
this.Left = frame.Left;
this.Top = frame.Top;
this.Width = frame.Width;
this.Height = frame.Height;
this.Time = frame.Time;
}
this.Apply = function(element)
{
element.style.left = Math.round(this.Left) + 'px';
element.style.top = Math.round(this.Top) + 'px';
element.style.width = Math.round(this.Width) + 'px';
element.style.height = Math.round(this.Height) + 'px';
}
}
The variables here are pretty simple - just holding a coordinate (left, top), a size (height, width), and a time. Then the object has two helper functions, Copy and Apply. The Copy function takes in another animation frame and copies the contents of the given frame into the current frame. The Apply function takes in an element and applies the values currently in the frame to the element (setting the element's position and size).
Ok, back to the AddFrame function in AnimationObject. Here is the code for it:
{
frames.push(frame);
}
Not too complicated. The frames private variable is an array of animation frames, specifying the animation sequence for the object. Thats right, an AnimationObject can have a full sequence of animations - it isn't just a simple 'go here' operation! This allows easy creation of complex animations (as you can see in the example at the bottom). One thing to note is that the frames array is never empty - it always has at least a single frame. This is because the first frame in the frames array always represents the original state of the element being animated.
Ok, next function - SetCallback:
{
callback = cb;
}
Another really simple function. All this does is set the callback local variable to the given argument. The argument should be a function - and this function will be called whenever the animation sequence has been completed on the object. We will see exactly where the callback is called a little bit later.
Now on to ClearFrames:
{
if(running != 0)
this.Stop();
frames = new Array();
frames.push(new AnimationFrame(0,0,0,0,0));
frames[0].Time = 0;
frames[0].Left = parseInt(element.style.left);
frames[0].Top = parseInt(element.style.top);
frames[0].Width = parseInt(element.style.width);
frames[0].Height = parseInt(element.style.height);
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
}
This function essentially does a reset of the AnimationObject (but it does not affect the position/size of the element being animated). If the animation is currently running, it is stopped. Then we create a new frames array and populate the first element with the current state of the element. We also reset a couple other local variables. Now is as good a time as any to explain what those variables are, so here we go.
The variable running is exactly what it sounds like - it represents if the animation is running or not. It is slightly more complicated than just a boolean true/false, because the animation can be running in two different directions (forward and backward). So we use 0 to represent not running, -1 to represent backwards, and 1 to represent forwards.
The currentFI (or current frame index) is an index into the frames array. When the animation is running, it always points to the frame that we are currently trying to reach.
The prevDir variable stores the direction that the animation was previously moving. Here, like the running variable we have three states. 0 represents that the animation was not moving previously (in the case of a new animation, or, like here, after a ClearFrames call), 1 represents it used to be moving forward, and -1 means that the animation used to be moving backward.
The currentData is an animation frame, and when the animation is running, it holds the exact current state of the element being animated. The Time variable in currentData holds how far (i.e., how many milliseconds) into the current frame we were when the other variables in currentData were last updated.
That covers almost all the private variables - we will cover the rest as we come across them. Next up, we have ResetToStart:
{
if(running != 0)
this.Stop();
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
frames[0].Apply(element);
}
This function does exactly what it sounds like - say we were in the middle of an animation (or at the end) and we want the animation set back to the start. This is the function to call. We stop the animation if it is running, and reset a couple private variables. Then we Apply the frame at index 0 in the frames. Since the first frame in the frames array is always the starting position, by applying it to the animation element, we reset the animation element to its original state.
Next we have the complement to ResetToStart - ResetToEnd:
{
if(running != 0)
this.Stop();
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
frames[frames.length - 1].Apply(element);
}
This is the function to use if you happen to be impatient for an animation to complete. It stops the animation if it is running, resets some private variables, and then sets the current state of the animation element to whatever is at the end of the frames array. This essentially just places the element at wherever it would have ended up at the after the animation finished.
Now the ubiquitous Stop function (what animation system is complete without one?):
{
if(running == 0)
return;
if(timeoutID != -1)
clearTimeout(timeoutID);
prevDir = running;
running = 0;
}
This, very simply, stops the animation if it is running. The timoutID variable holds the id of the current timeout if there is currently one waiting for this animation object. Because we are stopping the animation, if there is one waiting, we clear it (because we don't want the animation advanced any farther). We set the prevDir variable to the value of the running - ensuring that we know which direction the animation was moving. Finally, we set the running variable to 0. One thing to note about the stop function - it does not clear any animation state. This means that we can resume the animation, moving either forward or backward, from exactly where we stopped it. If you actually want state reset, you need to call one of the reset functions, or the ClearFrames function.
Ok, now on to some actual content! Here we have the RunForward function:
{
if(running == 1)
return;
if(running == -1)
this.Stop();
if(frames.length == 1 || element == null)
return;
lastTick = new Date().getTime();
if(prevDir == 0)
{
currentFI = 1;
currentData.Time = 0;
currentData.Left = parseInt(element.style.left);
currentData.Top = parseInt(element.style.top);
currentData.Width = parseInt(element.style.width);
currentData.Height = parseInt(element.style.height);
frames[0].Copy(currentData);
}
else if(prevDir != 1)
{
currentFI++;
currentData.Time = frames[currentFI].Time - currentData.Time;
}
running = 1;
animate();
}
First off, if we are already moving forward in the animation, we return (running == 1). Next, if we are currently running backward, we stop the animation (running == -1). If there is only one frame (which would be the origin state frame) or the element to animate is null, we don't have anything to do, so we return. On the following line, we get the current tick of the system and place it in the lastTick private variable. During an animation, the lastTick variable stores the last system time (in ticks) at which animate function was called. It is used to calculate how much system time has passed since the last update.
If the prevDir variable is 0 (i.e, the animation is at a start or reset state), we set currentFI to 1 - because that will be the first frame we are trying to animate to. We also fill the currentData variable with the current state of the element, and set its time variable to 0 (since we have not moved at all into the next frame). We copy these values into the 'origin state' frame in the frames array (i.e., frame 0). The reason we do all this is in case the element has been moved elsewhere since the last time it was animated.
Otherwise, if prevDir is not 1, that means we were moving backwards the last time the animation was running. This means that the currentFI variable actually point to the frame previous to the one we now want to reach - so we increment it. We also flip the time variable on currentData - since we were running backward, it was the amount of time spent moving toward the previous frame - and instead we want the amount of time that we are away from the previous frame.
Finally, we set the running variable to 1, and call the function animate (which we will get to in short order).
The next function is very similar to RunForward - it is RunBackward:
{
if(running == -1)
return;
if(running == 1)
this.Stop();
if(frames.length == 1 || element == null)
return;
lastTick = new Date().getTime();
//Start from the end
if(prevDir == 0)
{
currentFI = frames.length-2;
currentData.Left = parseInt(element.style.left);
currentData.Top = parseInt(element.style.top);
currentData.Width = parseInt(element.style.width);
currentData.Height = parseInt(element.style.height);
currentData.Time = frames[frames.length-1].Time;
frames[frames.length-1].Copy(currentData);
currentData.Time = 0;
}
else if(prevDir != -1)
{
currentData.Time = frames[currentFI].Time - currentData.Time;
currentFI--;
}
running = -1;
animate();
}
The structure of this function is identical to RunForward. In fact, they could be easily combined - but it would make the code a bit harder to read and understand. The major differences are in the prevDir if block. When prevDir is equal to 0, we assume that the current position of the element is actually at the end of the animation sequence (since RunBackward is being called). So we set the currentFI to frames.length - 2. The reason we don't set it to frames.length - 1 is because we are already at the position specific in that frame. This means that the last frame in our frames array becomes our "origin frame" when running backward, not the first frame.
The other difference here is that we need to keep the Time variable in the last frame correct (we can't just set it to 0). The reason for this gets a little weird. When moving forward, we look to the Time variable of the frame coming up for time info. So if we are moving between frames 1 and 2, the currentFI variable holds 2 and we look to frame 2 for timing info. But when moving backward, to keep the look and feel of the animation consistent with how it moves forward, we look to the previous frame for timing info (previous here means the frame previously animated to - not previously in the frames array). So when moving between frames 2 and 1, the currentFI variable holds 1, but we need to look at frame 2 for timing info. This way, since it takes frame[2].Time time to get from frame 1 to frame 2, it takes the same amount of time to go from from frame 2 to frame 1.
So what that all boils down to (besides a bunch of weirdness in the animate function which we will see in a bit) is that while currentData.Time still ends up being 0 (because we have made no progress yet from frame frames.length-1 to frames.length-2), the last frame still keeps its old time value.
The other slight difference is the code for when we had been moving forward - first we flip the time variable around, and then we decrement the currentFI variable (since we are now trying to go backward in the frames array).
And now, for the grand finale! Here is the function that does all the real work:
{
if(running == 0)
return;
var curTick = new Date().getTime();
var tickCount = curTick - lastTick;
lastTick = curTick;
var timeLeft = frames[((running == -1) ? currentFI+1 : currentFI)].Time
- currentData.Time;
while(timeLeft <= tickCount)
{
currentData.Copy(frames[currentFI]);
currentData.Time = 0;
currentFI += running;
if(currentFI >= frames.length || currentFI < 0)
{
currentData.Apply(element);
lastTick = -1;
running = 0;
prevDir = 0;
if(callback != null)
callback();
return;
}
tickCount = tickCount - timeLeft;
timeLeft = frames[((running == -1) ? currentFI+1 : currentFI)].Time
- currentData.Time;
}
if(tickCount != 0)
{
currentData.Time += tickCount;
var ratio = currentData.Time/
frames[((running == -1) ? currentFI+1 : currentFI)].Time;
currentData.Left = frames[currentFI-running].Left +
(frames[currentFI].Left - frames[currentFI-running].Left) * ratio;
currentData.Top = frames[currentFI-running].Top +
(frames[currentFI].Top - frames[currentFI-running].Top) * ratio;
currentData.Width = frames[currentFI-running].Width +
(frames[currentFI].Width - frames[currentFI-running].Width) * ratio;
currentData.Height = frames[currentFI-running].Height +
(frames[currentFI].Height - frames[currentFI-running].Height) * ratio;
}
currentData.Apply(element);
timeoutID = setTimeout(animate, 33);
}
First off, if we aren't running, return. Not that this function should ever be called when we aren't running, but its a good thing to check. Next, we calculate the amount of time since the last call to animate, and once we have that value we set lastTick to the current time. We then figure out how much time is left until we reach our current destination frame, and put this in timeLeft. You can see the evidence of how the backward animation/time info issue makes the code ugly here, as we figure out which frame we should be getting timing info from.
Now, there is always the possibility that the current frame should have completed already - which is the case when timeLeft is less than tickCount. There is an even crazier possibility too - which is that the computer took so long before calling animate that multiple frames should have completed already. This is why we have a while loop there. So if we should have completed the current frame, we put the info from the current frame into currentData, and set currentData.Time to 0 since we are now at the beginning of moving to the next frame. We also modify the currentFI variable - if we are moving forward, it is incremented, and if we are moving backward it is decremented.
Now, if the new currentFI value is outside the bounds of the frame array, guess what? We have completed the animation. We tell currentData to apply its state to the element, reset some variables, call the callback (if its not null), and return.
But it is likely that we are not at the end of the animation. In that case, we modify tickCount by subtracting timeLeft. This essentially means that we have satisfied "timeLeft" of the time since the last animate call, and there is now "tickCount" of time left to satisfy. We recalculate timeLeft for the new frame, and now we hit the top of the while loop. If this new frame should have completed already, we enter the while loop again. Otherwise, only part of the new frame is completed, and so we move into the rest of the animate function.
If tickCount is now 0, it means that we have no more time to "use", so we don't do anything. But if its not 0, we get to move the element a bit. First, we increment currentData.Time by tickCount (since we should now be that much farther along). Then we get a ration of current amount of time passed to the next frame and total amount of time that we want to pass until the next frame. For instance, if we wanted a frame to take 100 milliseconds, and 50 milliseconds had passed, we would get a ratio of 50/100, or 1/2. This means that we would want to element to be halfway between the previous frame and the next frame.
The next couple lines figure out the difference between the previous and current frames, multiply them by the ratio, and then add them to the values for the previous frame. This way we are always calculating the new state of the element absolutely, not relatively. Believe me, you don't want it implemented using relative calculations - the floating point math error adds up and up, and things never end up in quite the right position.
Ok, so now we are at the end of animate. The currentData variable holds the new state for the element, whatever it may be, so we call Apply. Then, since we need the animation to continue, we call setTimeout(animate, 33), which will try and call animate again in 33 milliseconds. This gives us, on a perfect computer, 30 frames per second.
And here is all that code put together:
{
this.Left = left;
this.Top = top;
this.Width = width;
this.Height = height;
this.Time = time;
this.Copy = function(frame)
{
this.Left = frame.Left;
this.Top = frame.Top;
this.Width = frame.Width;
this.Height = frame.Height;
this.Time = frame.Time;
}
this.Apply = function(element)
{
element.style.left = Math.round(this.Left) + 'px';
element.style.top = Math.round(this.Top) + 'px';
element.style.width = Math.round(this.Width) + 'px';
element.style.height = Math.round(this.Height) + 'px';
}
}
function AnimationObject(element)
{
if(typeof(element) == "string")
element = document.getElementById(element);
var frames = null;
var timeoutID = -1;
var running = 0;
var currentFI = 0;
var currentData = null;
var lastTick = -1;
var callback = null;
var prevDir = 0;
this.AddFrame = function(frame)
{
frames.push(frame);
}
this.SetCallback = function(cb)
{
callback = cb;
}
this.ClearFrames = function()
{
if(running != 0)
this.Stop();
frames = new Array();
frames.push(new AnimationFrame(0,0,0,0,0));
frames[0].Time = 0;
frames[0].Left = parseInt(element.style.left);
frames[0].Top = parseInt(element.style.top);
frames[0].Width = parseInt(element.style.width);
frames[0].Height = parseInt(element.style.height);
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
}
this.ResetToStart = function()
{
if(running != 0)
this.Stop();
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
frames[0].Apply(element);
}
this.ResetToEnd = function()
{
if(running != 0)
this.Stop();
currentFI = 0;
prevDir = 0;
currentData = new AnimationFrame(0,0,0,0,0);
frames[frames.length - 1].Apply(element);
}
this.Stop = function()
{
if(running == 0)
return;
if(timeoutID != -1)
clearTimeout(timeoutID);
prevDir = running;
running = 0;
}
this.RunForward = function()
{
if(running == 1)
return;
if(running == -1)
this.Stop();
if(frames.length == 1 || element == null)
return;
lastTick = new Date().getTime();
//Start from the begining
if(prevDir == 0)
{
currentFI = 1;
currentData.Time = 0;
currentData.Left = parseInt(element.style.left);
currentData.Top = parseInt(element.style.top);
currentData.Width = parseInt(element.style.width);
currentData.Height = parseInt(element.style.height);
frames[0].Copy(currentData);
}
else if(prevDir != 1)
{
currentFI++;
currentData.Time = frames[currentFI].Time - currentData.Time;
}
running = 1;
animate();
}
this.RunBackward = function()
{
if(running == -1)
return;
if(running == 1)
this.Stop();
if(frames.length == 1 || element == null)
return;
lastTick = new Date().getTime();
//Start from the end
if(prevDir == 0)
{
currentFI = frames.length-2;
currentData.Left = parseInt(element.style.left);
currentData.Top = parseInt(element.style.top);
currentData.Width = parseInt(element.style.width);
currentData.Height = parseInt(element.style.height);
currentData.Time = frames[frames.length-1].Time;
frames[frames.length-1].Copy(currentData);
currentData.Time = 0;
}
else if(prevDir != -1)
{
currentData.Time = frames[currentFI].Time - currentData.Time;
currentFI--;
}
running = -1;
animate();
}
function animate()
{
if(running == 0)
return;
var curTick = new Date().getTime();
var tickCount = curTick - lastTick;
lastTick = curTick;
var timeLeft = frames[((running == -1) ? currentFI+1 : currentFI)].Time
- currentData.Time;
while(timeLeft <= tickCount)
{
currentData.Copy(frames[currentFI]);
currentData.Time = 0;
currentFI += running;
if(currentFI >= frames.length || currentFI < 0)
{
currentData.Apply(element);
lastTick = -1;
running = 0;
prevDir = 0;
if(callback != null)
callback();
return;
}
tickCount = tickCount - timeLeft;
timeLeft = frames[((running == -1) ? currentFI+1 : currentFI)].Time
- currentData.Time;
}
if(tickCount != 0)
{
currentData.Time += tickCount;
var ratio = currentData.Time /
frames[((running == -1) ? currentFI+1 : currentFI)].Time;
currentData.Left = frames[currentFI-running].Left +
(frames[currentFI].Left - frames[currentFI-running].Left) * ratio;
currentData.Top = frames[currentFI-running].Top +
(frames[currentFI].Top - frames[currentFI-running].Top) * ratio;
currentData.Width = frames[currentFI-running].Width +
(frames[currentFI].Width - frames[currentFI-running].Width) * ratio;
currentData.Height = frames[currentFI-running].Height +
(frames[currentFI].Height - frames[currentFI-running].Height) * ratio;
}
currentData.Apply(element);
timeoutID = setTimeout(animate, 33);
}
this.ClearFrames();
}
A small thing to note - we call this.ClearFrames(); right at the end of the AnimationObject creation - this sets all the private variables to the correct initial state. You can download this code (and the code for the examples) here.
Ok, now we have this great animation object - how do we use it? Below we have a nice example that uses multiple animation frames, as well as most of the other features:
The code for all this is actually pretty simple:
function makeAnimation()
{
example2 = new AnimationObject("ex2Box");
example2.AddFrame(new AnimationFrame(598,10,35,25,2000));
example2.AddFrame(new AnimationFrame(608,10,25,35,100));
example2.AddFrame(new AnimationFrame(608,205,25,35,1000));
example2.AddFrame(new AnimationFrame(598,215,35,25,100));
example2.AddFrame(new AnimationFrame(10,215,35,25,2000));
example2.AddFrame(new AnimationFrame(10,205,25,35,100));
example2.AddFrame(new AnimationFrame(10,10,25,35,1000));
example2.AddFrame(new AnimationFrame(10,10,35,25,100));
}
function ForwardEx2()
{
if(example2 == null)
makeAnimation();
example2.SetCallback(example2.RunForward);
example2.RunForward();
}
function StopEx2()
{
if(example2 == null)
makeAnimation();
example2.Stop();
}
function BackEx2()
{
if(example2 == null)
makeAnimation();
example2.SetCallback(example2.RunBackward);
example2.RunBackward();
}
function ResetEx2()
{
if(example2 == null)
makeAnimation();
example2.ResetToStart();
}
The ForwardEx2, StopEx2, BackEx2 and ResetEx2 are all hooked up to their respective buttons (Go!, Stop!, Back!, Reset!). If the animation object doesn't exist, we create it by calling makeAnimation. In that function we create the object and add all the needed animation frames - and really, the most difficult part of creating this example was figuring out the correct values for those animation frames.
For the ForwardEx2 and BackEx2 functions, we want the animation to run forever, so we set the callback to the example2.RunForward or the example2.RunBackward functions, respectivly. This causes the animation to start again as soon as it finishes. And thats about it!
I hope you enjoyed learning about this new generic animation object. If you have questions or comments, and especially any requests for "Generic Animation 3.0", leave it below.
03/19/2008 - 03:43
nice job :)
09/13/2008 - 22:30
I am getting an "Error in parsing value for property... declaration dropped" error attempting to implement this with a simple sliding box as in some of your other tutorials. Is there a simple solution for this?
08/07/2009 - 09:22
Excellent Tutorial. Really well explained.
I've made the 'AnimationFrame' function a private member of the 'AnimationObject'. I also instantiated the AnimationFrame object from within the 'AddFrame' privileged function.
So the AddFrame function becomes:
frames.push(new AnimationFrame(left, top, width, height, time));
}
This makes the code cleaner when you come to use the object; instead of having to write:
example.AddFrame(new AnimationFrame(0, 20, 150, 130, 500));
You can simply write:
example.AddFrame(0, 20, 150, 130, 500);
I'm no JavaScript expert so I'd be interested to know if my slight modification has any drawbacks?
04/28/2010 - 12:50
I am new to javascript and animation but I was able to follow the version 1 tutorial pretty successfully. The only thing missing from it was the important concept that you have to use inline style on each animated html element, otherwise all variables defining the current position of the object will be null. I guess that's apparent from the sliding menus tutorial, but it was not really explained...but this gets me thinking, could this script be altered to use offsetTop, offsetLeft, offsetWidth, and offsetHeight properties? What would be the advantages (if any) of that? Thanks for the tutorials: really amazing work!
03/09/2011 - 06:23
nice
Add Comment
[language] [/language]
Examples:
[javascript] [/javascript]
[actionscript] [/actionscript]
[csharp] [/csharp]
See here for supported languages.
Javascript must be enabled to submit anonymous comments - or you can login.