The Dynamics of Scrolling

By Drew McCormack

There has been quite a bit of discussion the last few days about the momentum-based scrolling that Apple uses on the iPhone. The discussion has largely been fanned by John Gruber’s Daring Fireball blog. He has been arguing for some time that one of the reasons web apps feel inferior on the iPhone to native Cocoa apps is that the WebKit-based scrolling doesn’t behave the same. A recent post pointed to a JavaScript framework that Apple is apparently using internally, and which does produce a comparable scrolling experience.

This got me wondering how difficult it would be to reproduce Apple’s momentum scrolling on your own in JavaScript. Is the reason no web developers mimic native scrolling that it is too difficult, or is it just laziness or the expectation that it is very difficult that stops them? Or is JavaScript just not up to the task? To find out, I decided to try. About 3 hours and 100 lines of JavaScript later, I have my answer. Now it’s your turn.

Physics

When I started working on the problem, I was all set to tackle it like the Physicist I was trained to be. I intended to give the page mass and acceleration, apply forces derived from Hooke’s Law, and solve Newton’s equations to propagate the scroll view in time. But after about 30 minutes, it became clear I was suffering from an acute case of over-engineering, and — just as in other physics-based projects — I started cutting corners.

I identified several different phases of scrolling, each of which required different equations of motion:

  • Scrolling with the finger on the screen.
  • Scrolling with the finger on the screen beyond the end of the viewable content (ie rubberbanding).
  • Momentum scrolling with no finger on the screen.
  • Decelerating from momentum scrolling after passing the end of the content (ie first half of bounce).
  • Scrolling back to the start of the content after overshooting (ie second half of bounce).

By addressing each of these phases separately, I was able to mimic reasonably well Apple’s iPhone scrolling.

Source Code

You can test the scrolling here (Photo by TimboDon).

The source code I developed is a proof of concept, rather than a finished piece of production code, but nonetheless shows how you can approach it. Here it all is, HTML, CSS, and JavaScript in one:

<html>
<head>  

<style type="text/css">

.scrollview {
    position:relative;
    overflow:hidden;
    width:300px;
    height:400px;
    background-color:black;
}

.scrollviewcontent {
    position:absolute;
    top:0px;
    left:0px;
    width:100%;
    height:800px;
    background-color:gray;
    background-image:url(http://farm3.static.flickr.com/2242/2383475731_26167652d2_o_d.jpg);
}
</style>

<script type="text/javascript" language="javascript">

var scrollrange = 400.0;
var bounceheight = 200.0;
var animationtimestep = 1/20.0;
var mousedownpoint = null;
var translatedmousedownpoint = null;
var currentmousepoint = null;
var animationtimer = null;
var velocity = 0;
var position = 0;
var returntobaseconst = 1.5;
var decelerationconst = 100.0;
var bouncedecelerationconst = 1500.0;

function scrollviewdown() {
    if ( animationtimer ) stopanimation();
    mousedownpoint = event.screenY;
    translatedmousedownpoint = mousedownpoint;
    currentmousepoint = mousedownpoint;
    animationtimer = setInterval("updatescrollview()", animationtimestep);
}

function scrollviewup() {
    mousedownpoint = null;
    currentmousepoint = null;
    translatedmousedownpoint = null;
}

function scrollviewmove() {
    if ( !mousedownpoint ) return;
    currentmousepoint = event.screenY;
}

function updatescrollview() {
    var oldvelocity = velocity;

    // If mouse is still down, just scroll instantly to point
    if ( mousedownpoint ) {
        // First assume not beyond limits
        var displacement = currentmousepoint - translatedmousedownpoint;
        velocity = displacement / animationtimestep;
        translatedmousedownpoint = currentmousepoint;

        // If scrolled beyond top or bottom, dampen velocity to prevent going 
        // beyond bounce height
        if ( (position > 0 && velocity > 0) || ( position < -1 * scrollrange && velocity < 0) ) {
            var displace = ( position > 0 ? position : position + scrollrange );
            velocity *= (1.0 - Math.abs(displace) / bounceheight);
        }
    }
    else {
        if ( position > 0 ) {
            // If reach the top bound, bounce back
            if ( velocity <= 0 ) {
                // Return to 0 position
                velocity = -1 * returntobaseconst * Math.abs(position);
            }
            else {
                // Slow down in order to turn around
                var change = bouncedecelerationconst * animationtimestep;
                velocity -= change;
            }
        }
        else if ( position < -1 * scrollrange ) {
            // If reach bottom bound, bounce back
            if ( velocity >= 0 ) {
                // Return to bottom position
                velocity = returntobaseconst * Math.abs(position + scrollrange);
            }
            else {
                // Slow down
                var change = bouncedecelerationconst * animationtimestep;
                velocity += change;
            }
        }
        else {
            // Free scrolling. Decelerate gradually.
            var changevelocity = decelerationconst * animationtimestep;
            if ( changevelocity > Math.abs(velocity) ) {
                velocity = 0;
                stopanimation();
            }
            else {
                velocity -= (velocity > 0 ? +1 : -1) * changevelocity;
            }
        }
    }

    // Update position
    position += velocity * animationtimestep;

    // Update view
    scrollviewcontent = document.getElementById("thescrollviewcontent");
    scrollviewcontent.style.top = Math.round(position) + 'px';
}

function stopanimation() {
    clearInterval(animationtimer);
    animationtimer = null;
}

</script>

</head>

<body>

<div id="thescrollview" class="scrollview">
    <div id="thescrollviewcontent" class="scrollviewcontent"
    onmousedown="scrollviewdown();" 
    onmouseup="scrollviewup();"
    onmouseout="scrollviewup();"
    onmousemove="scrollviewmove();">
    </div>
</div>  

</body>

Copy this to a text file, save it, and open it in Safari on your Mac for testing.

Algorithm

Hopefully you can make out the various phases discussed above. When your finger is on the screen, the content should generally follow it immediately. This is the same as scrolling on the Mac when you use a hand tool in a drawing application. It is quite easy to implement simply by determining the displacement from one event to the next, and translating the content by that amount.

Things get a bit more tricky when you go beyond the end of the content. You need a different algorithm, because you don’t want the content to completely disappear. It should rubberband, so that the user cannot scroll it beyond a given distance. To implement this, I simply scaled the velocity such that it was zero at the bounce limit.

velocity *= (1.0 - Math.abs(displace) / bounceheight);

When the page is scrolling freely, with no finger on the screen, and not crossing any content boundaries, it should slowly decelerate, as if a small frictional force is in effect. To do this, a constant deceleration was used.

// Free scrolling. Decelerate gradually.
var changevelocity = decelerationconst * animationtimestep;
...
else {
    velocity -= (velocity > 0 ? +1 : -1) * changevelocity;
}

Probably the trickiest phases involve bouncing, when the user is not touching the screen. If a freely scrolling view crosses a content boundary, it first needs to slow to a stop, and then return neatly to the boundary edge. The initial slowdown is achieved by applying a fixed deceleration, all be it much more abrupt than the frictional slowdown.

// Slow down in order to turn around
var change = bouncedecelerationconst * animationtimestep;
velocity -= change;

The second half of the bounce is effectively an ease-out animation, with the velocity scaled according to the distance between the edge of the screen and the edge of the content.

// Return to 0 position
velocity = -1 * returntobaseconst * Math.abs(position);

Conclusion

All in all, not terribly complex. The brilliance of Apple’s scrolling lies not in the technical details so much as the interface design. They’ve already come up with the design — implementing it is the easy bit.

I’m a hack JavaScript programmer. I use it rarely, and have to Google everything I do, from converting numbers to strings, to handling events. If I can implement this in JavaScript in a few hours, what could a good web developer do?

Comments

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.

Maybe a reason why others don't do it.

United States Patent
#7434173
October 7, 2008
Scrolling web pages using direct interaction

... etc.

A need also exists in the art for optimizing scrolling or panning of pages on a computer over long distances and for making the scrolling action more natural to the user in terms of inertia of motion of the page.

... etc.

thanks!-

-lance

Awesome, Drew

Thanks for the insights, that was a fun little project!

MobileSafari Weirdness

I guess the problem is more that MobileSafari treats things (especially events) differently than a website. For example the mousemove event does not work as it commonly works in browsers.
I only tried shortly, however even by setting the viewport meta and the HTML document size to match the iPhone screen, and by following Apples Event Handling Guidelines, I couldn't make your example work on the iPhone. On the other hand, Googles Latitude for example works just as we would expect (of course the map does not bounce ;) ), so it must somehow be possible.

Awesome!

great project! something I can chew on this weekend.

MobileSafari

Yes, I noticed it didn't work on iPhone too, but I assume that is a question of preventing the web view intercepting the events. I assume it is possible, and that an experienced iPhone web dev would know how to do it, but I admit I didn't look into this aspect. I was more interested in the mechanics of the scrolling itself.

Drew

---------------------------
Drew McCormack
http://www.mentalfaculty.com
http://www.macanics.net
http://www.macresearch.org

The likely reason why web developers don't do it

I'm a web developer as well as a Cocoa developer. The likely reason why most web developers don't bother to implement this is because most websites are browsed on a computer with a keyboard and mouse, not using their fingertips.

Dynamic scrolling isn't necessary when most mice have a scroll wheel as well as the scroll bar down the right side. Both of these can be controlled and tuned for the user at the OS level. Overriding that operation would cause frustration and break the accepted usage of the device. That website would now be the "odd man out" and people navigating the website would likely experience irritation towards the website instead of excitement.

The real solution would be for "browser developers" to implement that as an option so that the operation remains consistent and isn't dependant on JavaScript or the whim of web developers. If this became common practice, there would be a variety of algorithms out there, each of them with their own method for scrolling. The last thing the web development world needs is more inconsistencies.

I should note that I'm not trying to be down on the idea. If it was implemented at the OS level or the browser level, then I would likely use it. It's just simply not something that web developers should be doing.

Re: The Likely Reason...

I certainly agree this scrolling should not be used for a standard web site. The article was prompted by discussion about native iPhone apps versus web iPhone apps. It was suggested that one problem with web apps on iPhone is they don't feel right when scrolling. I was just trying to show that it shouldn't be too difficult to get scrolling to 'feel right' for an iPhone web app, even if Apple is not playing ball.

But I certainly agree the ultimate solution is that Apple just fix this, or make sample code available for those that want better scrolling.

Drew

---------------------------
Drew McCormack
http://www.mentalfaculty.com
http://www.macanics.net
http://www.macresearch.org