Joe Hewitt

Fast animation with iOS WebKit

While building Scrollability I've learned a few tricks about how to make animations that start fast and run fast on iOS WebKit. The challenge of getting the scrolling physics right was nothing compared to the chore of making the animations as smooth and stutter-free as they are in native iOS apps. It took several iterations to get results that are acceptable.

First problem: cubic bezier curves

My motivation in starting Scrollability was that I was disappointed with iScroll, the most popular touch scrolling library out there. iScroll uses CSS transitions with the cubic-bezier timing function, and the results don't feel anything close to iOS native scrolling. Cubic bezier curves are simply unable to match the deceleration curve that Apple uses, so I knew right off the bat that CSS transitions could not be part of my solution.

Play with the iScroll demo and tell me if it doesn't immediately feel wrong to you, especially the way it bounces back at the edges.

Solution: animate with JavaScript, not CSS

My first iteration used JavaScript and setInterval to animate the -webkit-transform CSS property using translate3d. This achieved hardware acceleration, and it looked amazingly good on an iPad, but not nearly as good on an iPhone 4. While the physics were improved over iScroll, the animation was not nearly as smooth as the results iScroll got using CSS transitions.

Second problem: pixel doubling

Pixel doubling means that even though the iPhone 4 screen is 640 pixels wide, WebKit reports it as 320 pixels wide. The smallest increment you can animate something with JavaScript is two physical pixels, since WebKit always rounds coordinates up to the nearest integer before doubling them. That's right, writing translate3d(0, 0.5, 0) is the same as writing translate3d(0, 1, 0), and both will be multiplied to translate3d(0, 2, 0) in real coordinates. While you might be able to use the meta viewport tag to avoid pixel doubling, that causes a host of other headaches that I didn't want to burden Scrollability users with.

Play with this early Scrollability demo on an iPhone 4 (with iOS 4) and you will see how jerky the animations feels.

Updatetest by @amadeus shows, floating point values are not rounded as I thought. I'll have to do more research into why my first Scrollability animation was so poor on retina displays.

Solution: CSS keyframe animations

CSS keyframe animations, like CSS transitions, have the ability to animate in physical pixel increments on retina displays, but give you much more control over timing than cubic-bezier functions. Rather than simulating the physics progressively at each setInterval callback, I would simulate them all up front when the user released their finger, and use the resulting positions and times to create a fine-grained CSS keyframe animation like this:

@-webkit-keyframes scrollability {
    0% {
        -webkit-transform: translate3d(0, 10px, 0);
    }

    1% {
        -webkit-transform: translate3d(0, 11px, 0);
    }

    ... a bunch more keyframes ...

    99% {
        -webkit-transform: translate3d(0, 290px, 0);
    }

    100% {
        -webkit-transform: translate3d(0, 292px, 0);
    }
}

Turns out you can generate this CSS, insert it into a stylesheet, and start it playing faster than the wink of an eye. The results were just as good as I had hoped they would be.

Third problem: image animations stutter

While keyframe animations made my vertical scrolling demo fly, they caused a severe slowdown in my photo gallery demo. I knew the slowdown wasn't JavaScript-related, since my table demo was so fast. It had to be deep in the guts of WebKit. After much experimentation I noticed that galleries with only three photos did not stutter. Turns out the problem occurred only when the width of the animated element was greater than 1024 pixels.

Third solution: avoid repaints

WebKit does all of its painting on the CPU and then uploads the results to the GPU in the form of an OpenGL texture. This process is slow, but once you have that texture, it can be drawn to the screen incredibly fast. This is the key to native scrolling speed.

Turns out that on iPhones, WebKit creates textures that are no larger than 1024 by 1024, and if your element is larger than that, it has to create multiple textures. It really doesn't want to do that, due to limited GPU memory, so it creates these textures only when it needs to, and then throws them away immediately after. In our case, the textures are created at the start of a CSS animation and then discarded when it ends. Therefore, the stutter in my photo gallery is caused by those large images being painted on the CPU and pushed to new textures on the GPU at the start of the animation.

However, if your scrollable element only requires one texture, there is no stuttering, since that texture is probably already on the GPU when the animation begins. The solution was as simple as setting a max-width of 1024px on my photo gallery demo. Ok, it isn't that simple, since I have to do some other tricks to continually reposition the gallery images within that 1024px container, but the results are really excellent.

Fourth problem: iOS 4 WebKit sucks

Since this is the Web, where good things are always too good to be true, the 1024px texture trick works great on iOS 5, but has no benefit whatsoever on iOS 4.

Play with the photo gallery demo on iOS 5, but grab a box of tissues to dry your tears before you try it again on iOS 4. Kudos to Apple for optimizing iOS 5, but what do we do in the mean time?

Solution: wait for iOS 4 to die

No, seriously. If you want to use Scrollability, you need to have a fallback for legacy browsers anyway, and so I'm going to recommend that you limit Scrollability to iOS 5 WebKit. It's the future, right?