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.
Table of Contents
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?
Leave a Reply