ftscroller
Version:
FTScroller is a cross-browser Javascript/CSS library to allow touch, mouse or scrollwheel scrolling within specified elements, with pagination, snapping and bouncing support.
1,254 lines (1,042 loc) • 104 kB
JavaScript
/**
* FTScroller: touch and mouse-based scrolling for DOM elements larger than their containers.
*
* While this is a rewrite, it is heavily inspired by two projects:
* 1) Uxebu TouchScroll (https://github.com/davidaurelio/TouchScroll), BSD licensed:
* Copyright (c) 2010 uxebu Consulting Ltd. & Co. KG
* Copyright (c) 2010 David Aurelio
* 2) Zynga Scroller (https://github.com/zynga/scroller), MIT licensed:
* Copyright 2011, Zynga Inc.
* Copyright 2011, Deutsche Telekom AG
*
* Includes CubicBezier:
*
* Copyright (C) 2008 Apple Inc. All Rights Reserved.
* Copyright (C) 2010 David Aurelio. All Rights Reserved.
* Copyright (C) 2010 uxebu Consulting Ltd. & Co. KG. All Rights Reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC., DAVID AURELIO, AND UXEBU
* CONSULTING LTD. & CO. KG ``AS IS'' AND ANY EXPRESS OR IMPLIED
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL APPLE INC. OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
* IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*
* @copyright The Financial Times Ltd [All rights reserved]
* @codingstandard ftlabs-jslint
* @version 0.7.0
*/
/**
* @license FTScroller is (c) 2012 The Financial Times Ltd [All rights reserved] and licensed under the MIT license.
*
* Inspired by Uxebu TouchScroll, (c) 2010 uxebu Consulting Ltd. & Co. KG and David Aurelio, which is BSD licensed (https://github.com/davidaurelio/TouchScroll)
* Inspired by Zynga Scroller, (c) 2011 Zynga Inc and Deutsche Telekom AG, which is MIT licensed (https://github.com/zynga/scroller)
* Includes CubicBezier, (c) 2008 Apple Inc [All rights reserved], (c) 2010 David Aurelio and uxebu Consulting Ltd. & Co. KG. [All rights reserved], which is 2-clause BSD licensed (see above or https://github.com/davidaurelio/TouchScroll).
*/
/*jslint nomen: true, vars: true, browser: true, continue: true, white: true*/
/*globals FTScrollerOptions*/
var FTScroller, CubicBezier;
(function () {
'use strict';
// Determine the browser engine and prefix, trying to use the unprefixed version where available.
var _vendorCSSPrefix, _vendorStylePropertyPrefix, _vendorTransformLookup,
_pointerEventsPrefixed, _setPointerCapture, _releasePointerCapture, _lostPointerCapture, _trackPointerEvents, _pointerTypeTouch;
if (document.createElement('div').style.transform !== undefined) {
_vendorCSSPrefix = '';
_vendorStylePropertyPrefix = '';
_vendorTransformLookup = 'transform';
} else if (window.opera && Object.prototype.toString.call(window.opera) === '[object Opera]') {
_vendorCSSPrefix = '-o-';
_vendorStylePropertyPrefix = 'O';
_vendorTransformLookup = 'OTransform';
} else if (document.documentElement.style.MozTransform !== undefined) {
_vendorCSSPrefix = '-moz-';
_vendorStylePropertyPrefix = 'Moz';
_vendorTransformLookup = 'MozTransform';
} else if (document.documentElement.style.webkitTransform !== undefined) {
_vendorCSSPrefix = '-webkit-';
_vendorStylePropertyPrefix = 'webkit';
_vendorTransformLookup = '-webkit-transform';
} else if (typeof navigator.cpuClass === 'string') {
_vendorCSSPrefix = '-ms-';
_vendorStylePropertyPrefix = 'ms';
_vendorTransformLookup = '-ms-transform';
}
// Pointer Events are unprefixed in IE11
if ('pointerEnabled' in window.navigator) {
_pointerEventsPrefixed = false;
_trackPointerEvents = window.navigator.pointerEnabled;
_setPointerCapture = 'setPointerCapture';
_releasePointerCapture = 'releasePointerCapture';
_lostPointerCapture = 'lostpointercapture';
_pointerTypeTouch = 'touch';
} else if ('msPointerEnabled' in window.navigator) {
_pointerEventsPrefixed = true;
_trackPointerEvents = window.navigator.msPointerEnabled;
_setPointerCapture = 'msSetPointerCapture';
_releasePointerCapture = 'msReleasePointerCapture';
_lostPointerCapture = 'MSLostPointerCapture';
_pointerTypeTouch = 2; // PointerEvent.MSPOINTER_TYPE_TOUCH = 2 in IE10
}
// Global flag to determine if any scroll is currently active. This prevents
// issues when using multiple scrollers, particularly when they're nested.
var _ftscrollerMoving = false;
// Determine whether pointer events or touch events can be used
var _trackTouchEvents = !_trackPointerEvents;
// Determine whether to use modern hardware acceleration rules or dynamic/toggleable rules.
// Certain older browsers - particularly Android browsers - have problems with hardware
// acceleration, so being able to toggle the behaviour dynamically via a CSS cascade is desirable.
var _useToggleableHardwareAcceleration = false;
if ('hasOwnProperty' in window) {
_useToggleableHardwareAcceleration = !window.hasOwnProperty('ArrayBuffer');
}
// Feature detection
var _canClearSelection = (window.Selection && window.Selection.prototype.removeAllRanges);
// If hardware acceleration is using the standard path, but perspective doesn't seem to be supported,
// 3D transforms likely aren't supported either
if (!_useToggleableHardwareAcceleration && document.createElement('div').style[_vendorStylePropertyPrefix + (_vendorStylePropertyPrefix ? 'P' : 'p') + 'erspective'] === undefined) {
_useToggleableHardwareAcceleration = true;
}
// Style prefixes
var _transformProperty = _vendorStylePropertyPrefix + (_vendorStylePropertyPrefix ? 'T' : 't') + 'ransform';
var _transitionProperty = _vendorStylePropertyPrefix + (_vendorStylePropertyPrefix ? 'T' : 't') + 'ransition';
var _translateRulePrefix = _useToggleableHardwareAcceleration ? 'translate(' : 'translate3d(';
var _transformPrefixes = { x: '', y: '0,' };
var _transformSuffixes = { x: ',0' + (_useToggleableHardwareAcceleration ? ')' : ',0)'), y: (_useToggleableHardwareAcceleration ? ')' : ',0)') };
// Constants. Note that the bezier curve should be changed along with the friction!
var _kFriction = 0.998;
var _kMinimumSpeed = 0.01;
// Create a global stylesheet to set up stylesheet rules and track dynamic entries
(function () {
var stylesheetContainerNode = document.getElementsByTagName('head')[0] || document.documentElement;
var newStyleNode = document.createElement('style');
var hardwareAccelerationRule;
var _styleText;
newStyleNode.type = 'text/css';
// Determine the hardware acceleration logic to use
if (_useToggleableHardwareAcceleration) {
hardwareAccelerationRule = _vendorCSSPrefix + 'transform-style: preserve-3d;';
} else {
hardwareAccelerationRule = _vendorCSSPrefix + 'transform: translateZ(0);';
}
// Add our rules
_styleText = [
'.ftscroller_container { overflow: hidden; position: relative; max-height: 100%; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -ms-touch-action: none }',
'.ftscroller_hwaccelerated { ' + hardwareAccelerationRule + ' }',
'.ftscroller_x, .ftscroller_y { position: relative; min-width: 100%; min-height: 100%; overflow: hidden }',
'.ftscroller_x { display: inline-block }',
'.ftscroller_scrollbar { pointer-events: none; position: absolute; width: 5px; height: 5px; border: 1px solid rgba(255, 255, 255, 0.3); -webkit-border-radius: 3px; border-radius: 6px; opacity: 0; ' + _vendorCSSPrefix + 'transition: opacity 350ms; z-index: 10; -webkit-box-sizing: content-box; -moz-box-sizing: content-box; box-sizing: content-box }',
'.ftscroller_scrollbarx { bottom: 2px; left: 2px }',
'.ftscroller_scrollbary { right: 2px; top: 2px }',
'.ftscroller_scrollbarinner { height: 100%; background: #000; -webkit-border-radius: 2px; border-radius: 4px / 6px }',
'.ftscroller_scrollbar.active { opacity: 0.5; ' + _vendorCSSPrefix + 'transition: none; -o-transition: all 0 none }'
];
if (newStyleNode.styleSheet) {
newStyleNode.styleSheet.cssText = _styleText.join('\n');
} else {
newStyleNode.appendChild(document.createTextNode(_styleText.join('\n')));
}
// Add the stylesheet
stylesheetContainerNode.insertBefore(newStyleNode, stylesheetContainerNode.firstChild);
}());
/**
* Master constructor for the scrolling function, including which element to
* construct the scroller in, and any scrolling options.
* Note that app-wide options can also be set using a global FTScrollerOptions
* object.
*/
FTScroller = function (domNode, options) {
var key;
var destroy, setSnapSize, scrollTo, scrollBy, updateDimensions, addEventListener, removeEventListener, setDisabledInputMethods, _startScroll, _updateScroll, _endScroll, _finalizeScroll, _interruptScroll, _flingScroll, _snapScroll, _getSnapPositionForIndexes, _getSnapIndexForPosition, _constrainAndRenderTargetScrollPosition, _limitToBounds, _initializeDOM, _existingDOMValid, _domChanged, _updateDimensions, _updateScrollbarDimensions, _updateElementPosition, _updateSegments, _setAxisPosition, _getPosition, _scheduleAxisPosition, _fireEvent, _childFocused, _modifyDistanceBeyondBounds, _distancesBeyondBounds, _startAnimation, _scheduleRender, _cancelAnimation, _addEventHandlers, _removeEventHandlers, _resetEventHandlers, _onTouchStart, _onTouchMove, _onTouchEnd, _onMouseDown, _onMouseMove, _onMouseUp, _onPointerDown, _onPointerMove, _onPointerUp, _onPointerCancel, _onPointerCaptureEnd, _onClick, _onMouseScroll, _captureInput, _releaseInputCapture, _getBoundingRect;
/* Note that actual object instantiation occurs at the end of the closure to avoid jslint errors */
/* Options */
var _instanceOptions = {
// Whether to display scrollbars as appropriate
scrollbars: true,
// Enable scrolling on the X axis if content is available
scrollingX: true,
// Enable scrolling on the Y axis if content is available
scrollingY: true,
// The initial movement required to trigger a scroll, in pixels; this is the point at which
// the scroll is exclusive to this particular FTScroller instance.
scrollBoundary: 1,
// The initial movement required to trigger a visual indication that scrolling is occurring,
// in pixels. This is enforced to be less than or equal to the scrollBoundary, and is used to
// define when the scroller starts drawing changes in response to an input, even if the scroll
// is not treated as having begun/locked yet.
scrollResponseBoundary: 1,
// Whether to always enable scrolling, even if the content of the scroller does not
// require the scroller to function. This makes the scroller behave more like an
// element set to "overflow: scroll", with bouncing always occurring if enabled.
alwaysScroll: false,
// The content width to use when determining scroller dimensions. If this
// is false, the width will be detected based on the actual content.
contentWidth: undefined,
// The content height to use when determining scroller dimensions. If this
// is false, the height will be detected based on the actual content.
contentHeight: undefined,
// Enable snapping of content to 'pages' or a pixel grid
snapping: false,
// Define the horizontal interval of the pixel grid; snapping must be enabled for this to
// take effect. If this is not defined, snapping will use intervals based on container size.
snapSizeX: undefined,
// Define the vertical interval of the pixel grid; snapping must be enabled for this to
// take effect. If this is not defined, snapping will use intervals based on container size.
snapSizeY: undefined,
// Control whether snapping should be curtailed to only ever flick to the next page
// and not beyond. Snapping needs to be enabled for this to take effect.
singlePageScrolls: false,
// Allow scroll bouncing and elasticity near the ends and grid
bouncing: true,
// Allow a fast scroll to continue with momentum when released
flinging: true,
// Automatically detects changes to the contained markup and
// updates its dimensions whenever the content changes. This is
// set to false if a contentWidth or contentHeight are supplied.
updateOnChanges: true,
// Automatically catches changes to the window size and updates
// its dimensions.
updateOnWindowResize: false,
// The alignment to use if the content is smaller than the container;
// this also applies to initial positioning of scrollable content.
// Valid alignments are -1 (top or left), 0 (center), and 1 (bottom or right).
baseAlignments: { x: -1, y: -1 },
// Whether to use a window scroll flag, eg window.foo, to control whether
// to allow scrolling to start or now. If the window flag is set to true,
// this element will not start scrolling; this element will also toggle
// the variable while scrolling
windowScrollingActiveFlag: undefined,
// Instead of always using translate3d for transforms, a mix of translate3d
// and translate with a hardware acceleration class used to trigger acceleration
// is used; this is to allow CSS inheritance to be used to allow dynamic
// disabling of backing layers on older platforms.
hwAccelerationClass: 'ftscroller_hwaccelerated',
// While use of requestAnimationFrame is highly recommended on platforms
// which support it, it can result in the animation being a further half-frame
// behind the input method, increasing perceived lag slightly. To disable this,
// set this property to false.
enableRequestAnimationFrameSupport: true,
// Set the maximum time (ms) that a fling can take to complete; if
// this is not set, flings will complete instantly
maxFlingDuration: 1000,
// Whether to disable any input methods; on some multi-input devices
// custom behaviour may be desired for some scrollers. Use with care!
disabledInputMethods: {
mouse: false,
touch: false,
scroll: false,
pointer: false,
focus: false
},
// Define a scrolling class to be added to the scroller container
// when scrolling is active. Note that this can cause a relayout on
// scroll start if defined, but allows custom styling in response to scrolls
scrollingClassName: undefined,
// Bezier curves defining the feel of the fling (momentum) deceleration,
// the bounce decleration deceleration (as a fling exceeds the bounds),
// and the bounce bezier (used for bouncing back).
flingBezier: new CubicBezier(0.103, 0.389, 0.307, 0.966),
bounceDecelerationBezier: new CubicBezier(0, 0.5, 0.5, 1),
bounceBezier: new CubicBezier(0.7, 0, 0.9, 0.6),
// If the scroller is constrained to an x axis, convert y scroll to allow single-axis scroll
// wheels to scroll constrained content.
invertScrollWheel: true
};
/* Local variables */
// Cache the DOM node and set up variables for other nodes
var _publicSelf;
var _self = this;
var _scrollableMasterNode = domNode;
var _containerNode;
var _contentParentNode;
var _scrollNodes = { x: null, y: null };
var _scrollbarNodes = { x: null, y: null };
// Dimensions of the container element and the content element
var _metrics = {
container: { x: null, y: null },
content: { x: null, y: null, rawX: null, rawY: null },
scrollEnd: { x: null, y: null }
};
// Snapping details
var _snapGridSize = {
x: false,
y: false,
userX: false,
userY: false
};
var _snapIndex = {
x: 0,
y: 0
};
var _baseSegment = { x: 0, y: 0 };
var _activeSegment = { x: 0, y: 0 };
// Track the identifier of any input being tracked
var _inputIdentifier = false;
var _inputIndex = 0;
var _inputCaptured = false;
// Current scroll positions and tracking
var _isScrolling = false;
var _isDisplayingScroll = false;
var _isAnimating = false;
var _baseScrollPosition = { x: 0, y: 0 };
var _lastScrollPosition = { x: 0, y: 0 };
var _targetScrollPosition = { x: 0, y: 0 };
var _scrollAtExtremity = { x: null, y: null };
var _preventClick = false;
var _timeouts = [];
var _hasBeenScrolled = false;
// Gesture details
var _baseScrollableAxes = {};
var _scrollableAxes = { x: true, y: true };
var _gestureStart = { x: 0, y: 0, t: 0 };
var _cumulativeScroll = { x: 0, y: 0 };
var _eventHistory = [];
// Allow certain events to be debounced
var _domChangeDebouncer = false;
var _scrollWheelEndDebouncer = false;
// Performance switches on browsers supporting requestAnimationFrame
var _animationFrameRequest = false;
var _reqAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame || false;
var _cancelAnimationFrame = window.cancelAnimationFrame || window.cancelRequestAnimationFrame || window.mozCancelAnimationFrame || window.mozCancelRequestAnimationFrame || window.webkitCancelAnimationFrame || window.webkitCancelRequestAnimationFrame || window.msCancelAnimationFrame || window.msCancelRequestAnimationFrame || false;
// Event listeners
var _eventListeners = {
'scrollstart': [],
'scroll': [],
'scrollend': [],
'segmentwillchange': [],
'segmentdidchange': [],
'reachedstart': [],
'reachedend': [],
'scrollinteractionend': []
};
// MutationObserver instance, when supported and if DOM change sniffing is enabled
var _mutationObserver;
/* Parsing supplied options */
// Override default instance options with global - or closure'd - options
if (typeof FTScrollerOptions === 'object' && FTScrollerOptions) {
for (key in FTScrollerOptions) {
if (FTScrollerOptions.hasOwnProperty(key) && _instanceOptions.hasOwnProperty(key)) {
_instanceOptions[key] = FTScrollerOptions[key];
}
}
}
// Override default and global options with supplied options
if (options) {
for (key in options) {
if (options.hasOwnProperty(key)) {
// If a deprecated flag was passed in, warn, and convert to the new flag name
if ('paginatedSnap' === key) {
console.warn('FTScroller: "paginatedSnap" is deprecated; converting to "singlePageScrolls"');
_instanceOptions.singlePageScrolls = options.paginatedSnap;
continue;
}
if (_instanceOptions.hasOwnProperty(key)) {
_instanceOptions[key] = options[key];
}
}
}
// If snap grid size options were supplied, store them
if (options.hasOwnProperty('snapSizeX') && !isNaN(options.snapSizeX)) {
_snapGridSize.userX = _snapGridSize.x = options.snapSizeX;
}
if (options.hasOwnProperty('snapSizeY') && !isNaN(options.snapSizeY)) {
_snapGridSize.userY = _snapGridSize.y = options.snapSizeY;
}
// If content width and height were defined, disable updateOnChanges for performance
if (options.contentWidth && options.contentHeight) {
options.updateOnChanges = false;
}
}
// Validate the scroll response parameter
_instanceOptions.scrollResponseBoundary = Math.min(_instanceOptions.scrollBoundary, _instanceOptions.scrollResponseBoundary);
// Update base scrollable axes
if (_instanceOptions.scrollingX) {
_baseScrollableAxes.x = true;
}
if (_instanceOptions.scrollingY) {
_baseScrollableAxes.y = true;
}
// Only enable animation frame support if the instance options permit it
_reqAnimationFrame = _instanceOptions.enableRequestAnimationFrameSupport && _reqAnimationFrame;
_cancelAnimationFrame = _reqAnimationFrame && _cancelAnimationFrame;
/* Scoped Functions */
/**
* Unbinds all event listeners to prevent circular references preventing items
* from being deallocated, and clean up references to dom elements. Pass in
* "removeElements" to also remove FTScroller DOM elements for special reuse cases.
*/
destroy = function destroy(removeElements) {
var i, l;
_removeEventHandlers();
_cancelAnimation();
if (_domChangeDebouncer) {
window.clearTimeout(_domChangeDebouncer);
_domChangeDebouncer = false;
}
for (i = 0, l = _timeouts.length; i < l; i = i + 1) {
window.clearTimeout(_timeouts[i]);
}
_timeouts.length = 0;
// Destroy DOM elements if required
if (removeElements && _scrollableMasterNode) {
while (_contentParentNode.firstChild) {
_scrollableMasterNode.appendChild(_contentParentNode.firstChild);
}
_scrollableMasterNode.removeChild(_containerNode);
}
_scrollableMasterNode = null;
_containerNode = null;
_contentParentNode = null;
_scrollNodes.x = null;
_scrollNodes.y = null;
_scrollbarNodes.x = null;
_scrollbarNodes.y = null;
for (i in _eventListeners) {
if (_eventListeners.hasOwnProperty(i)) {
_eventListeners[i].length = 0;
}
}
// If this is currently tracked as a scrolling instance, clear the flag
if (_ftscrollerMoving && _ftscrollerMoving === _self) {
_ftscrollerMoving = false;
if (_instanceOptions.windowScrollingActiveFlag) {
window[_instanceOptions.windowScrollingActiveFlag] = false;
}
}
};
/**
* Configures the snapping boundaries within the scrolling element if
* snapping is active. If this is never called, snapping defaults to
* using the bounding box, eg page-at-a-time.
*/
setSnapSize = function setSnapSize(width, height) {
_snapGridSize.userX = width;
_snapGridSize.userY = height;
_snapGridSize.x = width;
_snapGridSize.y = height;
// Ensure the content dimensions conform to the grid
_metrics.content.x = Math.ceil(_metrics.content.rawX / width) * width;
_metrics.content.y = Math.ceil(_metrics.content.rawY / height) * height;
_metrics.scrollEnd.x = _metrics.container.x - _metrics.content.x;
_metrics.scrollEnd.y = _metrics.container.y - _metrics.content.y;
_updateScrollbarDimensions();
// Snap to the new grid if necessary
_snapScroll();
_updateSegments(true);
};
/**
* Scroll to a supplied position, including whether or not to animate the
* scroll and how fast to perform the animation (pass in true to select a
* dynamic duration). The inputs will be constrained to bounds and snapped.
* If false is supplied for a position, that axis will not be scrolled.
*/
scrollTo = function scrollTo(left, top, animationDuration) {
var targetPosition, duration, positions, axis, maxDuration = 0, scrollPositionsToApply = {};
// If a manual scroll is in progress, cancel it
_endScroll(Date.now());
// Move supplied coordinates into an object for iteration, also inverting the values into
// our coordinate system
positions = {
x: -left,
y: -top
};
for (axis in _baseScrollableAxes) {
if (_baseScrollableAxes.hasOwnProperty(axis)) {
targetPosition = positions[axis];
if (targetPosition === false) {
continue;
}
// Constrain to bounds
targetPosition = Math.min(0, Math.max(_metrics.scrollEnd[axis], targetPosition));
// Snap if appropriate
if (_instanceOptions.snapping && _snapGridSize[axis]) {
targetPosition = Math.round(targetPosition / _snapGridSize[axis]) * _snapGridSize[axis];
}
// Get a duration
duration = animationDuration || 0;
if (duration === true) {
duration = Math.sqrt(Math.abs(_baseScrollPosition[axis] - targetPosition)) * 20;
}
// Trigger the position change
_setAxisPosition(axis, targetPosition, duration);
scrollPositionsToApply[axis] = targetPosition;
maxDuration = Math.max(maxDuration, duration);
}
}
// If the scroll had resulted in a change in position, perform some additional actions:
if (_baseScrollPosition.x !== positions.x || _baseScrollPosition.y !== positions.y) {
// Mark a scroll as having ever occurred
_hasBeenScrolled = true;
// If an animation duration is present, fire a scroll start event and a
// scroll event for any listeners to act on
_fireEvent('scrollstart', _getPosition());
_fireEvent('scroll', _getPosition());
}
if (maxDuration) {
_timeouts.push(setTimeout(function () {
var anAxis;
for (anAxis in scrollPositionsToApply) {
if (scrollPositionsToApply.hasOwnProperty(anAxis)) {
_lastScrollPosition[anAxis] = scrollPositionsToApply[anAxis];
}
}
_finalizeScroll();
}, maxDuration));
} else {
_finalizeScroll();
}
};
/**
* Alter the current scroll position, including whether or not to animate
* the scroll and how fast to perform the animation (pass in true to
* select a dynamic duration). The inputs will be checked against the
* current position.
*/
scrollBy = function scrollBy(horizontal, vertical, animationDuration) {
// Wrap the scrollTo function for simplicity
scrollTo(parseFloat(horizontal) - _baseScrollPosition.x, parseFloat(vertical) - _baseScrollPosition.y, animationDuration);
};
/**
* Provide a public method to detect changes in dimensions for either the content or the
* container.
*/
updateDimensions = function updateDimensions(contentWidth, contentHeight, ignoreSnapScroll) {
options.contentWidth = contentWidth || options.contentWidth;
options.contentHeight = contentHeight || options.contentHeight;
// Currently just wrap the private API
_updateDimensions(!!ignoreSnapScroll);
};
/**
* Add an event handler for a supported event. Current events include:
* scroll - fired whenever the scroll position changes
* scrollstart - fired when a scroll movement starts
* scrollend - fired when a scroll movement ends
* segmentwillchange - fired whenever the segment changes, including during scrolling
* segmentdidchange - fired when a segment has conclusively changed, after scrolling.
*/
addEventListener = function addEventListener(eventname, eventlistener) {
// Ensure this is a valid event
if (!_eventListeners.hasOwnProperty(eventname)) {
return false;
}
// Add the listener
_eventListeners[eventname].push(eventlistener);
return true;
};
/**
* Remove an event handler for a supported event. The listener must be exactly the same as
* an added listener to be removed.
*/
removeEventListener = function removeEventListener(eventname, eventlistener) {
var i;
// Ensure this is a valid event
if (!_eventListeners.hasOwnProperty(eventname)) {
return false;
}
for (i = _eventListeners[eventname].length; i >= 0; i = i - 1) {
if (_eventListeners[eventname][i] === eventlistener) {
_eventListeners[eventname].splice(i, 1);
}
}
return true;
};
/**
* Set the input methods to disable. No inputs methods are disabled by default.
* (object, default { mouse: false, touch: false, scroll: false, pointer: false, focus: false })
*/
setDisabledInputMethods = function setDisabledInputMethods(disabledInputMethods) {
var i, changed;
for (i in _instanceOptions.disabledInputMethods) {
disabledInputMethods[i] = !!disabledInputMethods[i];
if (_instanceOptions.disabledInputMethods[i] !== disabledInputMethods[i]) changed = true;
_instanceOptions.disabledInputMethods[i] = disabledInputMethods[i];
}
if (changed) {
_resetEventHandlers();
}
};
/**
* Start a scroll tracking input - this could be mouse, webkit-style touch,
* or ms-style pointer events.
*/
_startScroll = function _startScroll(inputX, inputY, inputTime, rawEvent) {
var triggerScrollInterrupt = _isAnimating;
// Opera fix
if (inputTime <= 0) {
inputTime = Date.now();
}
// If a window scrolling flag is set, and evaluates to true, don't start checking touches
if (_instanceOptions.windowScrollingActiveFlag && window[_instanceOptions.windowScrollingActiveFlag]) {
return false;
}
// If an animation is in progress, stop the scroll.
if (triggerScrollInterrupt) {
_interruptScroll();
} else {
// Allow clicks again, but only if a scroll was not interrupted
_preventClick = false;
}
// Store the initial event coordinates
_gestureStart.x = inputX;
_gestureStart.y = inputY;
_gestureStart.t = inputTime;
_targetScrollPosition.x = _lastScrollPosition.x;
_targetScrollPosition.y = _lastScrollPosition.y;
// Clear event history and add the start touch
_eventHistory.length = 0;
_eventHistory.push({ x: inputX, y: inputY, t: inputTime });
if (triggerScrollInterrupt) {
_updateScroll(inputX, inputY, inputTime, rawEvent, triggerScrollInterrupt);
}
return true;
};
/**
* Continue a scroll as a result of an updated position
*/
_updateScroll = function _updateScroll(inputX, inputY, inputTime, rawEvent, scrollInterrupt) {
var axis, otherScrollerActive, distancesBeyondBounds;
var initialScroll = false;
var gesture = {
x: inputX - _gestureStart.x,
y: inputY - _gestureStart.y
};
// Opera fix
if (inputTime <= 0) {
inputTime = Date.now();
}
// Update base target positions
_targetScrollPosition.x = _baseScrollPosition.x + gesture.x;
_targetScrollPosition.y = _baseScrollPosition.y + gesture.y;
// If scrolling has not yet locked to this scroller, check whether to stop scrolling
if (!_isScrolling) {
// Check the internal flag to determine if another FTScroller is scrolling
if (_ftscrollerMoving && _ftscrollerMoving !== _self) {
otherScrollerActive = true;
}
// Otherwise, check the window scrolling flag to see if anything else has claimed scrolling
else if (_instanceOptions.windowScrollingActiveFlag && window[_instanceOptions.windowScrollingActiveFlag]) {
otherScrollerActive = true;
}
// If another scroller was active, clean up and stop processing.
if (otherScrollerActive) {
_releaseInputCapture();
_inputIdentifier = false;
if (_isDisplayingScroll) {
_cancelAnimation();
if (!_snapScroll(true)) {
_finalizeScroll(true);
}
}
return;
}
}
// If not yet displaying a scroll, determine whether that triggering boundary
// has been exceeded
if (!_isDisplayingScroll) {
// Determine scroll distance beyond bounds
distancesBeyondBounds = _distancesBeyondBounds(_targetScrollPosition);
// Check scrolled distance against the boundary limit to see if scrolling can be triggered.
// If the scroll has been interrupted, trigger at once
if (!scrollInterrupt && (!_scrollableAxes.x || Math.abs(gesture.x) < _instanceOptions.scrollResponseBoundary) && (!_scrollableAxes.y || Math.abs(gesture.y) < _instanceOptions.scrollResponseBoundary)) {
return;
}
// Determine whether to prevent the default scroll event - if the scroll could still
// be triggered, prevent the default to avoid problems (particularly on PlayBook)
if (_instanceOptions.bouncing || scrollInterrupt || (_scrollableAxes.x && gesture.x && distancesBeyondBounds.x < 0) || (_scrollableAxes.y && gesture.y && distancesBeyondBounds.y < 0)) {
rawEvent.preventDefault();
}
// If bouncing is disabled, and already at an edge and scrolling beyond the edge, ignore the scroll for
// now - this allows other scrollers to claim if appropriate, allowing nicer nested scrolls.
if (!_instanceOptions.bouncing && !scrollInterrupt && (!_scrollableAxes.x || !gesture.x || distancesBeyondBounds.x > 0) && (!_scrollableAxes.y || !gesture.y || distancesBeyondBounds.y > 0)) {
// Prevent the original click now that scrolling would be triggered
_preventClick = true;
return;
}
// Trigger the start of visual scrolling
_startAnimation();
_isDisplayingScroll = true;
_hasBeenScrolled = true;
_isAnimating = true;
initialScroll = true;
} else {
// Prevent the event default. It is safe to call this in IE10 because the event is never
// a window.event, always a "true" event.
rawEvent.preventDefault();
}
// If not yet locked to a scroll, determine whether to do so
if (!_isScrolling) {
// If the gesture distance has exceeded the scroll lock distance, or snapping is active
// and the scroll has been interrupted, enter exclusive scrolling.
if ((scrollInterrupt && _instanceOptions.snapping) || (_scrollableAxes.x && Math.abs(gesture.x) >= _instanceOptions.scrollBoundary) || (_scrollableAxes.y && Math.abs(gesture.y) >= _instanceOptions.scrollBoundary)) {
_isScrolling = true;
_preventClick = true;
_ftscrollerMoving = _self;
if (_instanceOptions.windowScrollingActiveFlag) {
window[_instanceOptions.windowScrollingActiveFlag] = _self;
}
_fireEvent('scrollstart', _getPosition());
}
}
// Capture pointer if necessary
if (_isScrolling) {
_captureInput();
}
// Cancel text selections while dragging a cursor
if (_canClearSelection) {
window.getSelection().removeAllRanges();
}
// Ensure the target scroll position is affected by bounds and render if needed
_constrainAndRenderTargetScrollPosition();
// To aid render/draw coalescing, perform other one-off actions here
if (initialScroll) {
if (gesture.x > 0) {
_baseScrollPosition.x -= _instanceOptions.scrollResponseBoundary;
} else if(gesture.x < 0) {
_baseScrollPosition.x += _instanceOptions.scrollResponseBoundary;
}
if (gesture.y > 0) {
_baseScrollPosition.y -= _instanceOptions.scrollResponseBoundary;
} else if(gesture.y < 0) {
_baseScrollPosition.y += _instanceOptions.scrollResponseBoundary;
}
_targetScrollPosition.x = _baseScrollPosition.x + gesture.x;
_targetScrollPosition.y = _baseScrollPosition.y + gesture.y;
if (_instanceOptions.scrollingClassName) {
_containerNode.className += ' ' + _instanceOptions.scrollingClassName;
}
if (_instanceOptions.scrollbars) {
for (axis in _scrollableAxes) {
if (_scrollableAxes.hasOwnProperty(axis)) {
_scrollbarNodes[axis].className += ' active';
}
}
}
}
// Add an event to the event history, keeping it around twenty events long
_eventHistory.push({ x: inputX, y: inputY, t: inputTime });
if (_eventHistory.length > 30) {
_eventHistory.splice(0, 15);
}
};
/**
* Complete a scroll with a final event time if available (it may
* not be, depending on the input type); this may continue the scroll
* with a fling and/or bounceback depending on options.
*/
_endScroll = function _endScroll(inputTime, rawEvent) {
_releaseInputCapture();
_inputIdentifier = false;
_cancelAnimation();
_fireEvent('scrollinteractionend', {});
if (!_isScrolling) {
if (!_snapScroll(true) && _isDisplayingScroll) {
_finalizeScroll(true);
}
return;
}
// Modify the last movement event to include the end event time
_eventHistory[_eventHistory.length - 1].t = inputTime;
// Update flags
_isScrolling = false;
_isDisplayingScroll = false;
_ftscrollerMoving = false;
if (_instanceOptions.windowScrollingActiveFlag) {
window[_instanceOptions.windowScrollingActiveFlag] = false;
}
// Stop the event default. It is safe to call this in IE10 because
// the event is never a window.event, always a "true" event.
if (rawEvent) {
rawEvent.preventDefault();
}
// Trigger a fling or bounceback if necessary
if (!_flingScroll() && !_snapScroll()) {
_finalizeScroll();
}
};
/**
* Remove the scrolling class, cleaning up display.
*/
_finalizeScroll = function _finalizeScroll(scrollCancelled) {
var i, l, axis, scrollEvent, scrollRegex;
_isAnimating = false;
_isDisplayingScroll = false;
// Remove scrolling class if set
if (_instanceOptions.scrollingClassName) {
scrollRegex = new RegExp('(?:^|\\s)' + _instanceOptions.scrollingClassName + '(?!\\S)', 'g');
_containerNode.className = _containerNode.className.replace(scrollRegex, '');
}
if (_instanceOptions.scrollbars) {
for (axis in _scrollableAxes) {
if (_scrollableAxes.hasOwnProperty(axis)) {
_scrollbarNodes[axis].className = _scrollbarNodes[axis].className.replace(/ ?active/g, '');
}
}
}
// Store final position if scrolling occurred
_baseScrollPosition.x = _lastScrollPosition.x;
_baseScrollPosition.y = _lastScrollPosition.y;
scrollEvent = _getPosition();
if (!scrollCancelled) {
_fireEvent('scroll', scrollEvent);
_updateSegments(true);
}
// Always fire the scroll end event, including an argument indicating whether
// the scroll was cancelled
scrollEvent.cancelled = scrollCancelled;
_fireEvent('scrollend', scrollEvent);
// Restore transitions
for (axis in _scrollableAxes) {
if (_scrollableAxes.hasOwnProperty(axis)) {
_scrollNodes[axis].style[_transitionProperty] = '';
if (_instanceOptions.scrollbars) {
_scrollbarNodes[axis].style[_transitionProperty] = '';
}
}
}
// Clear any remaining timeouts
for (i = 0, l = _timeouts.length; i < l; i = i + 1) {
window.clearTimeout(_timeouts[i]);
}
_timeouts.length = 0;
};
/**
* Interrupt a current scroll, allowing a start scroll during animation to trigger a new scroll
*/
_interruptScroll = function _interruptScroll() {
var axis, i, l;
_isAnimating = false;
// Update the stored base position
_updateElementPosition();
// Ensure the parsed positions are set, also clearing transitions
for (axis in _scrollableAxes) {
if (_scrollableAxes.hasOwnProperty(axis)) {
_setAxisPosition(axis, _baseScrollPosition[axis], 16, _instanceOptions.bounceDecelerationBezier);
}
}
// Update segment tracking if snapping is active
_updateSegments(false);
// Clear any remaining timeouts
for (i = 0, l = _timeouts.length; i < l; i = i + 1) {
window.clearTimeout(_timeouts[i]);
}
_timeouts.length = 0;
};
/**
* Determine whether a scroll fling or bounceback is required, and set up the styles and
* timeouts required.
*/
_flingScroll = function _flingScroll() {
var i, axis, movementTime, movementSpeed, lastPosition, comparisonPosition, flingDuration, flingDistance, flingPosition, bounceDelay, bounceDistance, bounceDuration, bounceTarget, boundsBounce, modifiedDistance, flingBezier, timeProportion, boundsCrossDelay, flingStartSegment, beyondBoundsFlingDistance, baseFlingComponent;
var maxAnimationTime = 0;
var moveRequired = false;
var scrollPositionsToApply = {};
// If we only have the start event available, or flinging is disabled,
// or the scroll was triggered by a scrollwheel, no action required.
if (_eventHistory.length === 1 || !_instanceOptions.flinging || _inputIdentifier === 'scrollwheel') {
return false;
}
for (axis in _scrollableAxes) {
if (_scrollableAxes.hasOwnProperty(axis)) {
bounceDuration = 350;
bounceDistance = 0;
boundsBounce = false;
bounceTarget = false;
boundsCrossDelay = undefined;
// Re-set a default bezier curve for the animation for potential modification
flingBezier = _instanceOptions.flingBezier;
// Get the last movement speed, in pixels per millisecond. To do this, look at the events
// in the last 100ms and average out the speed, using a minimum number of two points.
lastPosition = _eventHistory[_eventHistory.length - 1];
comparisonPosition = _eventHistory[_eventHistory.length - 2];
for (i = _eventHistory.length - 3; i >= 0; i = i - 1) {
if (lastPosition.t - _eventHistory[i].t > 100) {
break;
}
comparisonPosition = _eventHistory[i];
}
// Get the last movement time. If this is zero - as can happen with
// some scrollwheel events on some platforms - increase it to 16ms as
// if the movement occurred over a single frame at 60fps.
movementTime = lastPosition.t - comparisonPosition.t;
if (!movementTime) {
movementTime = 16;
}
// Derive the movement speed
movementSpeed = (lastPosition[axis] - comparisonPosition[axis]) / movementTime;
// If there is little speed, no further action required except for a bounceback, below.
if (Math.abs(movementSpeed) < _kMinimumSpeed) {
flingDuration = 0;
flingDistance = 0;
} else {
/* Calculate the fling duration. As per TouchScroll, the speed at any particular
point in time can be calculated as:
{ speed } = { initial speed } * ({ friction } to the power of { duration })
...assuming all values are in equal pixels/millisecond measurements. As we know the
minimum target speed, this can be altered to:
{ duration } = log( { speed } / { initial speed } ) / log( { friction } )
*/
flingDuration = Math.log(_kMinimumSpeed / Math.abs(movementSpeed)) / Math.log(_kFriction);
/* Calculate the fling distance (before any bouncing or snapping). As per
TouchScroll, the total distance covered can be approximated by summing
the distance per millisecond, per millisecond of duration - a divergent series,
and so rather tricky to model otherwise!
So using values in pixels per millisecond:
{ distance } = { initial speed } * (1 - ({ friction } to the power
of { duration + 1 }) / (1 - { friction })
*/
flingDistance = movementSpeed * (1 - Math.pow(_kFriction, flingDuration + 1)) / (1 - _kFriction);
}
// Determine a target fling position
flingPosition = Math.floor(_lastScrollPosition[axis] + flingDistance);
// If bouncing is disabled, and the last scroll position and fling position are both at a bound,
// reset the fling position to the bound
if (!_instanceOptions.bouncing) {
if (_lastScrollPosition[axis] === 0 && flingPosition > 0) {
flingPosition = 0;
} else if (_lastScrollPosition[axis] === _metrics.scrollEnd[axis] && flingPosition < _lastScrollPosition[axis]) {
flingPosition = _lastScrollPosition[axis];
}
}
// In single-page-scroll mode, determine the page to snap to - maximum one page
// in either direction from the *start* page.
if (_instanceOptions.singlePageScrolls && _instanceOptions.snapping) {
flingStartSegment = -_lastScrollPosition[axis] / _snapGridSize[axis];
if (_baseSegment[axis] < flingStartSegment) {
flingStartSegment = Math.floor(flingStartSegment);
} else {
flingStartSegment = Math.ceil(flingStartSegment);
}
// If the target position will end up beyond another page, target that page edge
if (flingPosition > -(_baseSegment[axis] - 1) * _snapGridSize[axis]) {
bounceDistance = flingPosition + (_baseSegment[axis] - 1) * _snapGridSize[axis];
} else if (flingPosition < -(_baseSegment[axis] + 1) * _snapGridSize[axis]) {
bounceDistance = flingPosition + (_baseSegment[axis] + 1) * _snapGridSize[axis];
// Otherwise, if the movement speed was above the minimum velocity, continue
// in the move direction.
} else if (Math.abs(movementSpeed) > _kMinimumSpeed) {
// Determine the target segment
if (movementSpeed < 0) {
flingPosition = Math.floor(_lastScrollPosition[axis] / _snapGridSize[axis]) * _snapGridSize[axis];
} else {
flingPosition = Math.ceil(_lastScrollPosition[axis] / _snapGridSize[axis]) * _snapGridSize[axis];
}
flingDuration = Math.min(_instanceOptions.maxFlingDuration, flingDuration * (flingPosition - _lastScrollPosition[axis]) / flingDistance);
}
// In non-paginated snapping mode, snap to the nearest grid location to the target
} else if (_instanceOptions.snapping) {
bounceDistance = flingPosition - (Math.round(flingPosition / _snapGridSize[axis]) * _snapGridSize[axis]);
}
// Deal with cases where the target is beyond the bounds
if (flingPosition - bounceDistance > 0) {
bounceDistance = flingPosition;
boundsBounce = true;
} else if (flingPosition - bounceDistance < _metrics.scrollEnd[axis]) {
bounceDistance = flingPosition - _metrics.scrollEnd[axis];
boundsBounce = true;
}
// Amend the positions and bezier curve if necessary
if (bounceDistance) {
// If the fling moves the scroller beyond the normal scroll bounds, and
// the bounce is snapping the scroll back after the fling:
if (boundsBounce && _instanceOptions.bouncing && flingDistance) {
flingDistance = Math.floor(flingDistance);
if (flingPosition > 0) {
beyondBoundsFlingDistance = flingPosition - Math.max(0, _lastScrollPosition[axis]);
} else {
beyondBoundsFlingDistance = flingPosition - Math.min(_metrics.scrollEnd[axis], _lastScrollPosition[axis]);
}
baseFlingComponent = flingDistance - beyondBoundsFlingDistance;
// Determine the time proportion the original bound is along the fling curve
if (!flingDistance || !flingDuration) {
timeProportion = 0;
} else {
timeProportion = flingBezier._getCoordinateForT(flingBezier.getTForY((flingDistance - beyondBoundsFlingDistance) / flingDistance, 1 / flingDuration), flingBezier._p1.x, flingBezier._p2.x);
boundsCrossDelay = timeProportion * flingDuration;
}
// Eighth the distance beyonds the bounds
modifiedDistance = Math.ceil(beyondBoundsFlingDistance / 8);
// Further limit the bounce to half the container dimensions
if (Math.abs(modifiedDistance) > _metrics.container[axis] / 2) {
if (modifiedDistance < 0) {
modifiedDistance = -Math.floor(_metrics.container[axis] / 2);
} else {
modifiedDistance = Math.floor(_metrics.container[axis] / 2);
}
}
if (flingPosition > 0) {
bounceTarget = 0;
} else {
bounceTarget = _metrics.scrollEnd[axis];
}
// If the entire fling is a bounce, modify appropriately
if (timeProportion === 0) {
flingDuration = flingDuration / 6;
flingPosition = _lastScrollPosition[axis] + baseFlingComponent + modifiedDistance;
bounceDelay = flingDuration;
// Otherwise, take a new curve and add it to the timeout stack for the bounce
} else {
// The new bounce delay is the pre-boundary fling duration, plus a
// sixth of the post-boundary fling.
bounceDelay = (timeProportion + ((1 - timeProportion) / 6)) * flingDuration;
_scheduleAxisPosition(axis, (_lastScrollPosition[axis] + baseFlingComponent + modifiedDistance), ((1 - timeProportion) * flingDuration / 6), _instanceOptions.bounceDecelerationBezier, boundsCrossDelay);
// Modify the fling to match, clipping to prevent over-fling
flingBezier = flingBezier.divideAtX(bounceDelay / flingDuration, 1 / flingDuration)[0];
flingDuration = bounceDelay;
flingPosition = (_lastScrollPosition[axis] + baseFlingComponent + modifiedDistance);
}
// If the fling requires snapping to a snap location, and the bounce needs to
// reverse the fling direction after the fling completes:
} else if ((flingDistance < 0 && bounceDistance < flingDistance) || (flingDistance > 0 && bounceDistance > flingDistance)) {
// Shorten the original fling duration to reflect the bounce
flingPosition = flingPosition - Math.floor(flingDistance / 2);
bounceDistance = bounceDistance - Math.floor(flingDistance / 2);
bounceDuration = Math.sqrt(Math.abs(bounceDistance)) * 50;
bounceTarget = flingPosition - bounceDistance;
flingDuration = 350;
bounceDelay = flingDuration * 0.97;
// If the bounce is truncating the fling, or continuing the fling on in the same
// direction to hit the next boundary:
} else {
flingPosition = flingPosition - bounceDistance;
// If there was no fling distance originally, use the bounce details
if (!flingDistance) {
flingDuration = bounceDuration;
// If truncating the fling at a snapping edge:
} else if ((flingDistance < 0 && bounceDistance < 0) || (flingDistance > 0 && bounceDistance > 0)) {
timeProportion = flingBezier._getCoordinateForT(flingBezier.getTForY((Math.abs(flingDistance) - Math.abs(bounceDistance)) / Math.abs(flingDistance), 1 / flingDuration), flingBezier._p1.x, flingBezier._p2.x);
flingBezier = flingBezier.divideAtX(timeProportion, 1 / flingDuration)[0];
flingDuration = Math.round(flingDuration * timeProportion);
// If extending the fling to reach the next snapping boundary, no further
// action is required.
}
bounceDistance = 0;
bounceDuration = 0;
}
}
// If no fling or bounce is required, continue
if (flingPosition === _lastScrollPosition[axis] && !bounceDistance) {
continue;
}
moveRequired = true;
// Perform the fling
_setAxisPosition(axis, flingPosition, flingDuration, flingBezier, boundsCrossDelay);
// Schedule a bounce if appropriate
if (bounceDistance && bounceDuration) {
_scheduleAxisPosition(axis, bounceTarget, bounceDuration, _instanceOptions.bounceBezier, bounceDelay);
}
maxAnimationTime = Math.max(maxAnimationTime, bounceDi