tl;dr? Click here.
Momentum – or inertial – based scrolling is one of the fairly ‘new’ phenomena introduced by touch devices in the last few years. Compared with a regular (linear) scroll, momentum scrolling offers a more natural way of scrolling that is easier on the eye and a lot more graceful. By default, momentum scrolling is used in most touch-based devices when scrolling webpages that extend past edges of your browser’s screen (a.k.a. overflowing content). In webkit browsers, the effect can also be ‘activated’ on elements having the css properties overflow: auto;
or overflow: scroll;
by setting a webkit specific css property: webkit-overflow-scrolling: touch;
. However, all this is in vain when you want to apply it to stuff like a <canvas>
or if you hook into touch events and need to use preventDefault();
when handling them.
I was frustrated by this when creating a simple web-app base layer recently. It hooked into touch events (using a library, TouchSwipe) to enable horizontal swiping, or navigating, within the webapp. For vertical swiping, I wanted my content to scroll. I could have just detected a vertical scroll, and set a stopPropagation();
to resume default browser behavior and enabling momentum scrolling, but that did not cut it: I wanted to only scroll the element I wanted to scroll, and leave other things like the body and document static; if the user would swipe on anything but scrollable elements, I wanted my content to stay in place (by default, a rubber band effect will occur when scrolling past the end of a page for example, I did not want that to happen). So I bound the vertical touchmove events to do a scrollTop
on the element so it would scroll. Happy times; this worked fine.
Hoewever, this did not look as pretty as the momentum scrolling that most touch-based devices produce. As I could not enable this native behaviour (see above), I needed to add some kind of simulated momentum scrolling. This was a lot more work than I expected. I first tried out various (pre-built) solutions by others, like Droidscroll and jQuery.kinetic, but they did not meet my demands (mainly, they either did ‘too much’, broke the rest of my scripts by hijacking the touch events or did both), so I had to ‘reinvent’ momentum scrolling myself.
The idea
I have tried numerous methods for simulating momentum scrolling, and after a fair amount of experiments finally settled on the following principle, based on three touch events:
touchstart
– get the coordinates of the touch event and store them, create a timer for the start timetouchmove
– calculate distance by comparing the coordinates of the touch event to the coordinates stored ontouchstart
and calculate acceleration (or speed) of the touch movement by dividing distance by (current time – start time). In the meantime scroll the element.touchend
– calculate touchtime by comparing the current time to the last touchmove time, calculate offset by taking the acceleration to the power of 2, times the window height or the scrolling element’s width, depending on direction. Then, scroll the element for an extra amount of pixels (offset), using quartic ‘out’ easing (which seems to best match the native momentum scrolling curve), for a pre-set duration.
To my amazement, this produced very nice results, and after some tweaking I managed to get pretty close to native momentum scrolling. At least; good enough for me.
Demo
Click here for a demo, or scroll the code example further below. Be sure to view it on a touch-enabled device (or use debug tools to override and emulate touch with your mouse, like in the Chrome developer tools).
Code
Below is the full annotated code for the inertial scroller at the time of writing. Paste it into a .js file and include it in your page. Then set the elements that need to scroll to have the class ‘inertialScroll’ (or modify the code to accept another class). The code should be non-unobtrusive and thus should not wreck any existing (touch based) handlers (but I am by no means a jQuery expert, so try it out for yourself). Feel free to modify and adapt the code (a mention would of course be nice)
/**
* jQuery Inertial Scroller v1.5
* (c)2013 hnldesign.nl
* This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/.
**/
/*jslint browser: true*/
/*global $, jQuery*/
/* SETTINGS */
var i_v = {
i_touchlistener : '.inertialScroll', // element to monitor for touches, set to null to use document. Otherwise use quotes. Eg. '.myElement'. Note: if the finger leaves this listener while still touching, movement is stopped.
i_scrollElement : '.inertialScroll', // element (class) to be scrolled on touch movement
i_duration : window.innerHeight * 1.5, // (ms) duration of the inertial scrolling simulation. Devices with larger screens take longer durations (phone vs tablet is around 500ms vs 1500ms). This is a fixed value and does not influence speed and amount of momentum.
i_speedLimit : 1.2, // set maximum speed. Higher values will allow faster scroll (which comes down to a bigger offset for the duration of the momentum scroll) note: touch motion determines actual speed, this is just a limit.
i_handleY : true, // should scroller handle vertical movement on element?
i_handleX : true, // should scroller handle horizontal movement on element?
i_moveThreshold : 100, // (ms) determines if a swipe occurred: time between last updated movement @ touchmove and time @ touchend, if smaller than this value, trigger inertial scrolling
i_offsetThreshold : 30, // (px) determines, together with i_moveThreshold if a swipe occurred: if calculated offset is above this threshold
i_startThreshold : 5, // (px) how many pixels finger needs to move before a direction (horizontal or vertical) is chosen. This will make the direction detection more accurate, but can introduce a delay when starting the swipe if set too high
i_acceleration : 0.5, // increase the multiplier by this value, each time the user swipes again when still scrolling. The multiplier is used to multiply the offset. Set to 0 to disable.
i_accelerationT : 300 // (ms) time between successive swipes that determines if the multiplier is increased (if lower than this value)
};
/* stop editing here */
//set some required vars
i_v.i_time = {};
i_v.i_elem = null;
i_v.i_elemH = null;
i_v.i_elemW = null;
i_v.multiplier = 1;
// Define easing function. This is based on a quartic 'out' curve. You can generate your own at http://www.timotheegroleau.com/Flash/experiments/easing_function_generator.htm
if ($.easing.hnlInertial === undefined) {
$.easing.hnlInertial = function (x, t, b, c, d) {
"use strict";
var ts = (t /= d) * t, tc = ts * t;
return b + c * (-1 * ts * ts + 4 * tc + -6 * ts + 4 * t);
};
}
$(i_v.i_touchlistener || document)
.on('touchstart touchmove touchend', function (e) {
"use strict";
//prevent default scrolling
e.preventDefault();
//store timeStamp for this event
i_v.i_time[e.type] = e.timeStamp;
})
.on('touchstart', function (e) {
"use strict";
this.tarElem = $(e.target);
this.elemNew = this.tarElem.closest(i_v.i_scrollElement).length > 0 ? this.tarElem.closest(i_v.i_scrollElement) : $(i_v.i_scrollElement).eq(0);
//dupecheck, optimizes code a bit for when the element selected is still the same as last time
this.sameElement = i_v.i_elem ? i_v.i_elem[0] == this.elemNew[0] : false;
//no need to redo these if element is unchanged
if (!this.sameElement) {
//set the element to scroll
i_v.i_elem = this.elemNew;
//get dimensions
i_v.i_elemH = i_v.i_elem.innerHeight();
i_v.i_elemW = i_v.i_elem.innerWidth();
//check element for applicable overflows and reevaluate settings
this.i_scrollableY = !!((i_v.i_elemH < i_v.i_elem.prop('scrollHeight') && i_v.i_handleY));
this.i_scrollableX = !!((i_v.i_elemW < i_v.i_elem.prop('scrollWidth') && i_v.i_handleX));
}
//get coordinates of touch event
this.pageY = e.originalEvent.touches[0].pageY;
this.pageX = e.originalEvent.touches[0].pageX;
if (i_v.i_elem.is(':animated') && (i_v.i_time.touchstart - i_v.i_time.touchend) < i_v.i_accelerationT) {
//user swiped while still animating, increase the multiplier for the offset
i_v.multiplier += i_v.i_acceleration;
} else {
//else reset multiplier
i_v.multiplier = 1;
}
i_v.i_elem
//stop any animations still running on element (this enables 'tap to stop')
.stop(true, false)
//store current scroll positions of element
.data('scrollTop', i_v.i_elem.scrollTop())
.data('scrollLeft', i_v.i_elem.scrollLeft());
})
.on('touchmove', function (e) {
"use strict";
//check if startThreshold is met
this.go = (Math.abs(this.pageX - e.originalEvent.touches[0].pageX) > i_v.i_startThreshold || Math.abs(this.pageY - e.originalEvent.touches[0].pageY) > i_v.i_startThreshold);
})
.on('touchmove touchend', function (e) {
"use strict";
//check if startThreshold is met
if (this.go) {
//set animpar1 to be array
this.animPar1 = {};
//handle events
switch (e.type) {
case 'touchmove':
this.vertical = Math.abs(this.pageX - e.originalEvent.touches[0].pageX) < Math.abs(this.pageY - e.originalEvent.touches[0].pageY); //find out in which direction we are scrolling
this.distance = this.vertical ? this.pageY - e.originalEvent.touches[0].pageY : this.pageX - e.originalEvent.touches[0].pageX; //determine distance between touches
this.acc = Math.abs(this.distance / (i_v.i_time.touchmove - i_v.i_time.touchstart)); //calculate acceleration during movement (crucial)
//determine which property to animate, reset animProp first for when no criteria is matched
this.animProp = null;
if (this.vertical && this.i_scrollableY) { this.animProp = 'scrollTop'; } else if (!this.vertical && this.i_scrollableX) { this.animProp = 'scrollLeft'; }
//set animation parameters
if (this.animProp) { this.animPar1[this.animProp] = i_v.i_elem.data(this.animProp) + this.distance; }
this.animPar2 = { duration: 0 };
break;
case 'touchend':
this.touchTime = i_v.i_time.touchend - i_v.i_time.touchmove; //calculate touchtime: the time between release and last movement
this.i_maxOffset = (this.vertical ? i_v.i_elemH : i_v.i_elemW) * i_v.i_speedLimit; //(re)calculate max offset
//calculate the offset (the extra pixels for the momentum effect
this.offset = Math.pow(this.acc, 2) * (this.vertical ? i_v.i_elemH : i_v.i_elemW);
this.offset = (this.offset > this.i_maxOffset) ? this.i_maxOffset : this.offset;
this.offset = (this.distance < 0) ? -i_v.multiplier * this.offset : i_v.multiplier * this.offset;
//if the touchtime is low enough, the offset is not null and the offset is above the offsetThreshold, (re)set the animation parameters to include momentum
if ((this.touchTime < i_v.i_moveThreshold) && this.offset !== 0 && Math.abs(this.offset) > (i_v.i_offsetThreshold)) {
if (this.animProp) { this.animPar1[this.animProp] = i_v.i_elem.data(this.animProp) + this.distance + this.offset; }
this.animPar2 = { duration: i_v.i_duration, easing : 'hnlInertial', complete: function () {
//reset multiplier
i_v.multiplier = 1;
}};
}
break;
}
// run the animation on the element
if ((this.i_scrollableY || this.i_scrollableX) && this.animProp) {
i_v.i_elem.stop(true, false).animate(this.animPar1, this.animPar2);
}
}
});
View/fork on Github: https://gist.github.com/c-kick/32798f731803f08dc469
Options
Options are set in the i_v
object:
- i_touchlistener element to monitor for touches, set to
null
to use document. Otherwise use quotes. Eg.'.myElement'
. Note: if the finger leaves this listener while still touching, movement is stopped. - i_scrollElement element (class) to be scrolled on touch movement. Use quotes. Eg.
'.myElement'
. - i_duration (ms) duration of the inertial scrolling simulation. Devices with larger screens take longer durations (phone vs tablet is around 500ms vs 1500ms). This is a fixed value and does not influence speed and amount of momentum.
- i_speedLimit (int) set maximum speed. Higher values will allow faster scroll (which comes down to a bigger offset for the duration of the momentum scroll) note: touch motion by the user determines actual speed, this is just a limit.
- i_handleY (bool) should scroller handle vertical touch movement on i_scrollElement?
- i_handleX (bool) should scroller handle horizontal touch movement on i_scrollElement?
- i_moveThreshold (ms) determines if a swipe occurred: time between last updated movement @ touchmove and time @ touchend, if smaller than this value, trigger inertial scrolling
- i_offsetThreshold (px) determines, together with i_moveThreshold, if a swipe occurred: if calculated offset is above this threshold
- i_startThreshold (px) how many pixels finger needs to move before a direction (horizontal or vertical) is chosen. This will make the direction detection more accurate, but can introduce a delay when starting the swipe if set too high
- i_acceleration (int) increase the multiplier by this value, each time the user swipes again when still scrolling. The multiplier is used to multiply the offset. Set to 0 to disable.
- i_accelerationT (ms) time between successive swipes that determines if the multiplier is increased (if lower than this value)
Notes
- The script adjusts itself to available screenspace (by using
window.innerHeight
for vertical scrolling – or innerWidth of the scrolling element for horizontal scrolling – to modify the acceleration and offset variables). This is done in accordance to native momentum scrolling; on a smartphone a scroll takes less time, and ‘travels’ for a shorter distance, than on a tablet for example. Note that this is not recalculated on an orientation change (when the screen decreases or increases in height). While I found this not necessary to include, you can write code that adjusts duration, max offset and max acceleration on an orientation change. - The script wil also ‘lock’ into horizontal or vertical direction; if the user starts swiping vertically, then for the duration of the movement, horizontal movement is disabled, and vice versa.
- When the user swipes in quick succession, the offset will be multiplied (and thus scrolling is accelerated with each swipe), just like native scrolling. Parameters can be adjusted.
- Between the
touchmove
andtouchend
, the animation (scrolling) of the element ends and starts again (if enough momentum is detected). This can potentially produce a very slight pause on slow devices, or on scroll elements with lots of content (images, elaborate css, etc). I am still looking for a way to resolve this. Suggestions are more than welcome. - The jQuery
.animate
method is not hardware accelerated when it comes to scrolling (as opposed to physically moving an element), so the animation might not look as smooth as you’d expect, especially for scrolling elements with lots of content (images, elaborate css, etc). - The script uses a global object for its settings,
i_v
, so make sure you modify that if it conflicts with your own code.
Things this script does not provide (yet)
- Scroll back to top when the user taps iOS’ Safari’s status bar (there is no event for this, and it would surprise me if Apple introduced this).
- The ‘Rubber banding’-effect when the user has reached either the bottom or top of the scrolling element. Not to honor Steve Jobs’ wish, but because it is patented by Apple (and too complex to simulate in this manner). Instead, scrolling will just come to an abrupt halt. Potentially, this could be compensated somewhat by calculating remaining scrolling space, and adjusting variables accordingly, but I’ll leave that up to you.
- No native scrollbars (the thin black lines indicating position) will appear. This can be added though by coding some monitoring/updating to the scrollHeight property and the scrollTo event of the element and adding some styled divs to simulate the scrollbars.
Though for most webkit browsers, you can add::-webkit-scrollbar
css properties to get the scrollbars to show (see the demo).
I hope this is of use to someone as it was to me; just plain momentum scrolling, no extra’s.
jQuery Inertial Scroller by HN Leussink is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
[…] 此外,另一个不想看到的情况也出现了,当滑来滑去的尝试的时候,发现此时的滑动性能有点差。 因为我们设置滑动容器的高度为它本身的100%,这样就阻碍了ios上的momentum-based scrolling, 这里的momentum-based scrolling,我没有很好的语言来翻译,简称为阻尼滑动吧 简单而言就是移动设备上增加的一种旨在提升页面滑动性能的功能,比较明显的体现就是当你的手指轻触触碰设备表面时,页面自身开始滑动,当手指停止滑动之后页面还会顺势滑动一会。更多了解请转。我肯定是希望有这种效果的,所以要远离设置滑动元素height100%。 […]
[…] ended up using MOMENTUM SCROLLING USING JQUERY (hnldesign.nl) on floating menu, which prevents default browser scrolling and then animates scrolling itself. I […]
This is awesome! Exactly what I was looking for. Thanks!!
Thanks! This was just what I was looking for. For me the e.preventDefault() was stopping the links in my slideout menu from working, but even with that commented out this script works great.
Great work and many thanks!
I linked this page and quoted your code at http://stackoverflow.com/a/26095453/4078503
Why would you implement scrolling acceleration on your website when the native scrolling in one’s browser works much better? Your demo took away the rubber-band effect in my Safari browser that I’m normally used to.
I gather you did not read any of what I wrote above, as I address both the usage cases as the side effects.
Because I’m generally opposed to scrolling hacks. It’s much better to let the native browser scroll however it will.
Read the first paragraph. Or the last few sentences of it.
Great work! Problem is that if you move the finger fast > stop moving > and release it directly, it scrolls nevertheless… I think it should not animate then!