|
|
/*! * stroll.js 1.2 - CSS scroll effects * http://lab.hakim.se/scroll-effects
* MIT licensed * * Copyright (C) 2012 Hakim El Hattab, http://hakim.se
*/(function(){
"use strict";
// When a list is configured as 'live', this is how frequently
// the DOM will be polled for changes
var LIVE_INTERVAL = 500;
var IS_TOUCH_DEVICE = !!( 'ontouchstart' in window );
// All of the lists that are currently bound
var lists = [];
// Set to true when there are lists to refresh
var active = false;
/** * Updates all currently bound lists. */ function refresh() { if( active ) { requestAnimFrame( refresh );
for( var i = 0, len = lists.length; i < len; i++ ) { lists[i].update(); } } }
/** * Starts monitoring a list and applies classes to each of * its contained elements based on its position relative to * the list's viewport. * * @param {HTMLElement} element * @param {Object} options Additional arguments; * - live; Flags if the DOM should be repeatedly checked for changes * repeatedly. Useful if the list contents is changing. Use * scarcely as it has an impact on performance. */ function add( element, options ) { // Only allow ul/ol
if( !element.nodeName || /^(ul|ol)$/i.test( element.nodeName ) === false ) { return false; } // Delete duplicates (but continue and re-bind this list to get the
// latest properties and list items)
else if( contains( element ) ) { remove( element ); }
var list = IS_TOUCH_DEVICE ? new TouchList( element ) : new List( element );
// Handle options
if( options && options.live ) { list.syncInterval = setInterval( function() { list.sync.call( list ); }, LIVE_INTERVAL ); }
// Synchronize the list with the DOM
list.sync();
// Add this element to the collection
lists.push( list );
// Start refreshing if this was the first list to be added
if( lists.length === 1 ) { active = true; refresh(); } }
/** * Stops monitoring a list element and removes any classes * that were applied to its list items. * * @param {HTMLElement} element */ function remove( element ) { for( var i = 0; i < lists.length; i++ ) { var list = lists[i];
if( list.element == element ) { list.destroy(); lists.splice( i, 1 ); i--; } }
// Stopped refreshing if the last list was removed
if( lists.length === 0 ) { active = false; } }
/** * Checks if the specified element has already been bound. */ function contains( element ) { for( var i = 0, len = lists.length; i < len; i++ ) { if( lists[i].element == element ) { return true; } } return false; }
/** * Calls 'method' for each DOM element discovered in * 'target'. * * @param target String selector / array of UL elements / * jQuery object / single UL element * @param method A function to call for each element target */ function batch( target, method, options ) { var i, len;
// Selector
if( typeof target === 'string' ) { var targets = document.querySelectorAll( target );
for( i = 0, len = targets.length; i < len; i++ ) { method.call( null, targets[i], options ); } } // Array (jQuery)
else if( typeof target === 'object' && typeof target.length === 'number' ) { for( i = 0, len = target.length; i < len; i++ ) { method.call( null, target[i], options ); } } // Single element
else if( target.nodeName ) { method.call( null, target, options ); } else { throw 'Stroll target was of unexpected type.'; } }
/** * Checks if the client is capable of running the library. */ function isCapable() { return !!document.body.classList; }
/** * The basic type of list; applies past & future classes to * list items based on scroll state. */ function List( element ) { this.element = element; }
/** * Fetches the latest properties from the DOM to ensure that * this list is in sync with its contents. */ List.prototype.sync = function() { this.items = Array.prototype.slice.apply( this.element.children );
// Caching some heights so we don't need to go back to the DOM so much
this.listHeight = this.element.offsetHeight;
// One loop to get the offsets from the DOM
for( var i = 0, len = this.items.length; i < len; i++ ) { var item = this.items[i]; item._offsetHeight = item.offsetHeight; item._offsetTop = item.offsetTop; item._offsetBottom = item._offsetTop + item._offsetHeight; item._state = ''; }
// Force an update
this.update( true ); }
/** * Apply past/future classes to list items outside of the viewport */ List.prototype.update = function( force ) { var scrollTop = this.element.pageYOffset || this.element.scrollTop, scrollBottom = scrollTop + this.listHeight;
// Quit if nothing changed
if( scrollTop !== this.lastTop || force ) { this.lastTop = scrollTop;
// One loop to make our changes to the DOM
for( var i = 0, len = this.items.length; i < len; i++ ) { var item = this.items[i];
// Above list viewport
if( item._offsetBottom < scrollTop ) { // Exclusion via string matching improves performance
if( item._state !== 'past' ) { item._state = 'past'; item.classList.add( 'past' ); item.classList.remove( 'future' ); } } // Below list viewport
else if( item._offsetTop > scrollBottom ) { // Exclusion via string matching improves performance
if( item._state !== 'future' ) { item._state = 'future'; item.classList.add( 'future' ); item.classList.remove( 'past' ); } } // Inside of list viewport
else if( item._state ) { if( item._state === 'past' ) item.classList.remove( 'past' ); if( item._state === 'future' ) item.classList.remove( 'future' ); item._state = ''; } } } }
/** * Cleans up after this list and disposes of it. */ List.prototype.destroy = function() { clearInterval( this.syncInterval );
for( var j = 0, len = this.items.length; j < len; j++ ) { var item = this.items[j];
item.classList.remove( 'past' ); item.classList.remove( 'future' ); } }
/** * A list specifically for touch devices. Simulates the style * of scrolling you'd see on a touch device but does not rely * on webkit-overflow-scrolling since that makes it impossible * to read the up-to-date scroll position. */ function TouchList( element ) { this.element = element; this.element.style.overflow = 'hidden';
this.top = { value: 0, natural: 0 };
this.touch = { value: 0, offset: 0, start: 0, previous: 0, lastMove: Date.now(), accellerateTimeout: -1, isAccellerating: false, isActive: false };
this.velocity = 0; } TouchList.prototype = new List();
/** * Fetches the latest properties from the DOM to ensure that * this list is in sync with its contents. This is typically * only used once (per list) at initialization. */ TouchList.prototype.sync = function() { this.items = Array.prototype.slice.apply( this.element.children );
this.listHeight = this.element.offsetHeight;
var item;
// One loop to get the properties we need from the DOM
for( var i = 0, len = this.items.length; i < len; i++ ) { item = this.items[i]; item._offsetHeight = item.offsetHeight; item._offsetTop = item.offsetTop; item._offsetBottom = item._offsetTop + item._offsetHeight; item._state = '';
// Animating opacity is a MAJOR performance hit on mobile so we can't allow it
item.style.opacity = 1; }
this.top.natural = this.element.scrollTop; this.top.value = this.top.natural; this.top.max = item._offsetBottom - this.listHeight;
// Force an update
this.update( true );
this.bind(); }
/** * Binds the events for this list. References to proxy methods * are kept for unbinding if the list is disposed of. */ TouchList.prototype.bind = function() { var scope = this;
this.touchStartDelegate = function( event ) { scope.onTouchStart( event ); };
this.touchMoveDelegate = function( event ) { scope.onTouchMove( event ); };
this.touchEndDelegate = function( event ) { scope.onTouchEnd( event ); };
this.element.addEventListener( 'touchstart', this.touchStartDelegate, false ); this.element.addEventListener( 'touchmove', this.touchMoveDelegate, false ); this.element.addEventListener( 'touchend', this.touchEndDelegate, false ); }
TouchList.prototype.onTouchStart = function( event ) { event.preventDefault();
if( event.touches.length === 1 ) { this.touch.isActive = true; this.touch.start = event.touches[0].clientY; this.touch.previous = this.touch.start; this.touch.value = this.touch.start; this.touch.offset = 0;
if( this.velocity ) { this.touch.isAccellerating = true;
var scope = this;
this.touch.accellerateTimeout = setTimeout( function() { scope.touch.isAccellerating = false; scope.velocity = 0; }, 500 ); } else { this.velocity = 0; } } }
TouchList.prototype.onTouchMove = function( event ) { if( event.touches.length === 1 ) { var previous = this.touch.value;
this.touch.value = event.touches[0].clientY; this.touch.lastMove = Date.now();
var sameDirection = ( this.touch.value > this.touch.previous && this.velocity < 0 ) || ( this.touch.value < this.touch.previous && this.velocity > 0 );
if( this.touch.isAccellerating && sameDirection ) { clearInterval( this.touch.accellerateTimeout );
// Increase velocity significantly
this.velocity += ( this.touch.previous - this.touch.value ) / 10; } else { this.velocity = 0;
this.touch.isAccellerating = false; this.touch.offset = Math.round( this.touch.start - this.touch.value ); }
this.touch.previous = previous; } }
TouchList.prototype.onTouchEnd = function( event ) { var distanceMoved = this.touch.start - this.touch.value;
if( !this.touch.isAccellerating ) { // Apply velocity based on the start position of the touch
this.velocity = ( this.touch.start - this.touch.value ) / 10; }
// Don't apply any velocity if the touch ended in a still state
if( Date.now() - this.touch.lastMove > 200 || Math.abs( this.touch.previous - this.touch.value ) < 5 ) { this.velocity = 0; }
this.top.value += this.touch.offset;
// Reset the variables used to determne swipe speed
this.touch.offset = 0; this.touch.start = 0; this.touch.value = 0; this.touch.isActive = false; this.touch.isAccellerating = false;
clearInterval( this.touch.accellerateTimeout );
// If a swipe was captured, prevent event propagation
if( Math.abs( this.velocity ) > 4 || Math.abs( distanceMoved ) > 10 ) { event.preventDefault(); } };
/** * Apply past/future classes to list items outside of the viewport */ TouchList.prototype.update = function( force ) { // Determine the desired scroll top position
var scrollTop = this.top.value + this.velocity + this.touch.offset;
// Only scroll the list if there's input
if( this.velocity || this.touch.offset ) { // Scroll the DOM and add on the offset from touch
this.element.scrollTop = scrollTop;
// Keep the scroll value within bounds
scrollTop = Math.max( 0, Math.min( this.element.scrollTop, this.top.max ) );
// Cache the currently set scroll top and touch offset
this.top.value = scrollTop - this.touch.offset; }
// If there is no active touch, decay velocity
if( !this.touch.isActive || this.touch.isAccellerating ) { this.velocity *= 0.95; }
// Cut off early, the last fraction of velocity doesn't have
// much impact on movement
if( Math.abs( this.velocity ) < 0.15 ) { this.velocity = 0; }
// Only proceed if the scroll position has changed
if( scrollTop !== this.top.natural || force ) { this.top.natural = scrollTop; this.top.value = scrollTop - this.touch.offset;
var scrollBottom = scrollTop + this.listHeight;
// One loop to make our changes to the DOM
for( var i = 0, len = this.items.length; i < len; i++ ) { var item = this.items[i];
// Above list viewport
if( item._offsetBottom < scrollTop ) { // Exclusion via string matching improves performance
if( this.velocity <= 0 && item._state !== 'past' ) { item.classList.add( 'past' ); item._state = 'past'; } } // Below list viewport
else if( item._offsetTop > scrollBottom ) { // Exclusion via string matching improves performance
if( this.velocity >= 0 && item._state !== 'future' ) { item.classList.add( 'future' ); item._state = 'future'; } } // Inside of list viewport
else if( item._state ) { if( item._state === 'past' ) item.classList.remove( 'past' ); if( item._state === 'future' ) item.classList.remove( 'future' ); item._state = ''; } } } };
/** * Cleans up after this list and disposes of it. */ TouchList.prototype.destroy = function() { List.prototype.destroy.apply( this );
this.element.removeEventListener( 'touchstart', this.touchStartDelegate, false ); this.element.removeEventListener( 'touchmove', this.touchMoveDelegate, false ); this.element.removeEventListener( 'touchend', this.touchEndDelegate, false ); }
/** * Public API */ window.stroll = { /** * Binds one or more lists for scroll effects. * * @see #add() */ bind: function( target, options ) { if( isCapable() ) { batch( target, add, options ); } },
/** * Unbinds one or more lists from scroll effects. * * @see #remove() */ unbind: function( target ) { if( isCapable() ) { batch( target, remove ); } } }
window.requestAnimFrame = (function(){ return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function( callback ){ window.setTimeout(callback, 1000 / 60); }; })()
})();
|