/** @license Copyright (c) 2010 uxebu Consulting Ltd. & Co. KG Copyright (c) 2010 David Aurelio 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 THE COPYRIGHT HOLDERS AND CONTRIBUTORS "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 THE COPYRIGHT HOLDER 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. */ var TouchScroll = (function(){ // // SCROLLER CONFIGURATION // var config = { // the minimum move distance to trigger scrolling (in pixels) threshold: 5, // minimum scroll handle size scrollHandleMinSize: 25, // flicking detection and configuration flicking: { // longest duration between last touchmove and the touchend event to trigger flicking triggerThreshold: 150, // the friction factor (per milisecond). // This factor is used to precalculate the flick length. Lower numbers // make flicks decelerate earlier. friction: 0.998, // minimum speed needed before the animation stop (px/ms) // This value is used to precalculate the flick length. Larger numbers // lead to shorter flicking lengths and durations minSpeed: 0.15, // the timing function for flicking animinations (control points for a cubic bezier curve) timingFunc: [0, 0.3, 0.6, 1] }, // bouncing configuration elasticity: { // factor for the bounce length while dragging factorDrag: 0.5, // factor for the bounce length while flicking factorFlick: 0.2, // maximum bounce (in px) when flicking max: 100 }, // snap back configuration snapBack: { // the timing function for snap back animations (control points for a cubic bezier curve) // when bouncing out before, the first control point is overwritten to achieve a smooth // transition between bounce and snapback. timingFunc: [0.4, 0, 1, 1], // default snap back time defaultTime: 400, // whether the snap back effect always uses the default time or // uses the bounce out time. alwaysDefaultTime: true } }; // // FEATURE DETECTION // /* Determine touch events support */ var hasTouchSupport = (function(){ if("createTouch" in document){ // True on the iPhone return true; } try{ var event = document.createEvent("TouchEvent"); // Should throw an error if not supported return !!event.initTouchEvent; // Check for existance of initialization method }catch(error){ return false; } }()); /* In some older versions of Android, WebKitCSSMatrix is broken and does not parse a "matrix" directive properly. */ var parsesMatrixCorrectly = (function(){ var m = new WebKitCSSMatrix("matrix(1, 0, 0, 1, -20, -30)"); return m.e == -20 && m.f == -30; }()); // // FEATURE BASED CODE BRANCHING // /* Define event names */ var events; if(hasTouchSupport){ events = { start: "touchstart", move: "touchmove", end: "touchend", cancel: "touchcancel" }; }else{ events = { start: "mousedown", move: "mousemove", end: "mouseup", cancel: "touchcancel" // unnecessary here }; } var getMatrixFromNode; if(parsesMatrixCorrectly){ getMatrixFromNode = function(/*HTMLElement*/node){ /*WebKitCSSMatrix*/ var doc = node.ownerDocument, transform = window.getComputedStyle(node).webkitTransform; return new WebKitCSSMatrix(transform); } }else{ var reMatrix = /matrix\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/; getMatrixFromNode = function(/*HTMLElement*/node){ /*WebKitCSSMatrix*/ var doc = node.ownerDocument, transform = window.getComputedStyle(node).webkitTransform, matrix = new WebKitCSSMatrix(), match = reMatrix.exec(transform); if(match){ matrix.e = match[1]; matrix.f = match[2]; } return matrix; } } // // UTILITY FUNCTIONS // function setTransitionProperty(/*HTMLElement*/node){ node.style.webkitTransformStyle = "preserve-3d"; node.style.webkitTransitionProperty = "-webkit-transform"; }; function applyMatrixToNode(/*HTMLElement*/node, /*WebKitCSSMatrix*/matrix, /*String?*/duration, /*String?*/timingFunc){ var s = node.style; if(duration != null){ s.webkitTransitionDuration = duration + ""; } if(timingFunc != null){ s.webkitTransitionTimingFunction = timingFunc + ""; } // This is twice as fast as than directly assigning the matrix // to the style property (maybe because no function call is involved?). node.style.webkitTransform = "translate(" + matrix.e + "px, " + matrix.f + "px)"; } function getMatrixFromEvent(event){ /*WebKitCSSMatrix*/ if(event.touches && event.touches.length){ event = event.touches[0]; } var matrix = new WebKitCSSMatrix; matrix.e = event.pageX; matrix.f = event.pageY; return matrix; }; function roundMatrix(/*WebKitCSSMatrix*/matrix){ /*WebKitCSSMatrix*/ matrix.e = Math.round(matrix.e); matrix.f = Math.round(matrix.f); return matrix; } // A DOM node to clone for scrollbars var scrollbarsTemplate = document.createElement("div"); scrollbarsTemplate.innerHTML = [ '
', '
', '
', '
', '
', '
' ].join(""); /* === TOUCH CONTROLLER ====================================================== Does the actual work. The event handling is divided into two parts: The scroller constructor tracks "move", "end", and "cancel" events and delegates them to the currently active scroller, if any. Every single scroller only listens for the "start" event on its outer node, and sets itself as the currently active scroller. */ /* Every object with a "handleEvent" method can be registered as DOM Level 2 event listener. On event, the method is called on the registered object. */ TouchScroll.handleEvent = function handleEvent(event){ var currentScroller = TouchScroll.prototype.currentScroller; if(currentScroller){ currentScroller.handleEvent(event); }else if(event.type === events.move){ // always cancel move events at this point event.preventDefault(); } }; /* Listening to end, move, and cancel event. Event listening takesplace during bubbling, so other scripts can cancel scrolling by simply stopping event propagation. */ document.addEventListener(events.move, TouchScroll.handleEvent, false); document.addEventListener(events.end, TouchScroll.handleEvent, false); document.addEventListener(events.cancel, TouchScroll.handleEvent, false); /** Constructor for scrollers. @constructor @param {HTMLElement} scrollElement The node to make scrollable @param {Object} [options] Options for the scroller- Known options are elastic {Boolean} whether the scroller bounces */ function TouchScroll(/*HTMLElement*/scrollElement, /*Object*/options){ options = options || {}; this.elastic = !!options.elastic, this.snapToGrid = !!options.snapToGrid; this.containerSize = null; this.maxSegments = {e: 1, f: 1}; this.currentSegment = {e: 0, f: 0}; // references to scroll div elements this.scrollers = { container: scrollElement, outer: /*HTMLElement*/null, inner: /*HTMLElement*/null, e: /*HTMLElement*/null, f: /*HTMLElement*/null }; // Whether the scroller scrolls this._scrolls = {e: false, f: false}; // The minimal scroll values (fully scrolled to the bottom/right) // Object with attributes "e" and "f" this._scrollMin = {e: false, f: false}; // References DOM nodes for scrollbar tracks and handles. // Gets set up by "_initDom" // { // container: HTMLElement, // handles:{e: HTMLElement, f: HTMLElement}, // maxOffsets: {e: Number, f: Number}, -> maximum offsets for the handles // offsetRatios: {e: Number, f: Number}, -> Ratio of scroller offset to handle offset // sizes: {e: Number, f: Number}, -> handle sizes // tracks: {e: HTMLElement, f: HTMLElement}, // } this._scrollbars = null, /* ---- SCROLLER STATE ---- */ this._isScrolling = false; this._startEvent = null; // the current scroller offset this._currentOffset = new WebKitCSSMatrix(); // Events tracked during a move action // [ {timeStamp: Number, matrix: WebKitCSSMatrix} ] // The last two events get tracked. this._trackedEvents = /*Array*/null; // Keeps track whether flicking is active this._flicking = {e: false, f: false}; // Queued bounces this._bounces = {e: null, f: null}; // Animation timeouts // This implementation uses timeouts for combined animations, // because the webkitTransitionEnd event fires late on iPhone 3G this._animationTimeouts = {e: [], f: []}; this._initDom(); this.setupScroller(); } TouchScroll.prototype = { // references the currently active scroller // static property! currentScroller: null, // Maps event types to method names. handlerNames: { resize: "setupScroller", orientationchange: "setupScroller", webkitTransitionEnd: "onTransitionEnd", DOMSubtreeModified: "setupScroller", touchstart: "onTouchStart", mousedown: "onTouchStart", touchmove: "onTouchMove", mousemove: "onTouchMove", touchend: "onTouchEnd", mouseup: "onTouchEnd", touchcancel: "onTouchEnd" }, // Does DOM initialization needed for the scroller _initDom: function initDom(){ // wrap the scroller contents with two additional
elements var innerScroller = document.createElement("div"), outerScroller = innerScroller.cloneNode(false), parentNode = this.scrollers.container; innerScroller.className = "touchScrollInner"; parentNode.className += " touchScroll"; for(var i = 0, iMax = parentNode.childNodes.length; i < iMax; i++){ innerScroller.appendChild(parentNode.firstChild); } outerScroller.appendChild(innerScroller); parentNode.appendChild(outerScroller); this.scrollers.outer = this.scrollers.f = outerScroller; this.scrollers.inner = this.scrollers.e = innerScroller; // init scroll layers for transitions setTransitionProperty(outerScroller); setTransitionProperty(innerScroller); innerScroller.style.display = "inline-block"; innerScroller.style.minWidth = "100%"; innerScroller.style.webkitBoxSizing = "border-box"; // add scrollbars var scrollbarsNode = scrollbarsTemplate.cloneNode(true), trackE = scrollbarsNode.querySelector(".touchScrollTrackX"), trackF = scrollbarsNode.querySelector(".touchScrollTrackY"), handleE = trackE.firstElementChild, handleF = trackF.firstElementChild; var style = scrollbarsNode.style; style.pointerEvents = "none"; // make clicks/touches on scrollbars "fall through" style.webkitTransitionProperty = "opacity"; style.webkitTransitionDuration = "250ms"; style.opacity = "0"; var scrollbars = this._scrollbars = { container: scrollbarsNode, tracks: { e: trackE, f: trackF }, handles: { e: handleE, f: handleF }, sizes : {e: 0, f: 0} } setTransitionProperty(handleE); setTransitionProperty(handleF); parentNode.insertBefore(scrollbarsNode, outerScroller); /* Apply relative positioning to the scrolling container. This is needed to enable scrollbar positioning. */ if(window.getComputedStyle(parentNode).position == "static"){ parentNode.style.position = "relative"; } this.setupScroller(); // initialize event listeners parentNode.addEventListener(events.start, this, false); outerScroller.addEventListener("webkitTransitionEnd", this, false); outerScroller.addEventListener("DOMSubtreeModified", this, true); window.addEventListener("orientationchange", this, false); window.addEventListener("resize", this, false); }, setupScroller: function setupScroller(debug){ var scrollContainer = this.scrollers.outer.parentNode, containerSize = { e: scrollContainer.offsetWidth, f: scrollContainer.offsetHeight }, innerScroller = this.scrollers.inner, scrollerSize = { e: innerScroller.offsetWidth, f: innerScroller.offsetHeight }, scrollbars = this._scrollbars, scrollMin = { e: Math.min(containerSize.e - scrollerSize.e, 0), f: Math.min(containerSize.f - scrollerSize.f, 0) }; this.containerSize = containerSize; this.maxSegments = { e: Math.ceil(-scrollMin.e / containerSize.e), f: Math.ceil(-scrollMin.f / containerSize.f) }; scrollbars.container.style.height = containerSize.f + "px"; // Minimum scroll offsets this._scrollMin = scrollMin; var scrolls = this._scrolls = { e: scrollMin.e < 0, f: scrollMin.f < 0 }; this._doScroll = scrolls.e || scrolls.f; // scrollbar container class name changes if both scrollbars are visible scrollbars.container.className = "touchScrollBars"; if(scrolls.e && scrolls.f){ scrollbars.container.className += " touchScrollBarsBoth"; } // hide/show scrollbars scrollbars.tracks.e.style.display = scrolls.e ? "" : "none"; scrollbars.tracks.f.style.display = scrolls.f ? "" : "none"; var scrollbarTrackSizes = { e: scrollbars.tracks.e.offsetWidth, f: scrollbars.tracks.f.offsetHeight }; // calculate and apply scroll bar handle sizes scrollbars.sizes = { e: Math.round(Math.max( scrollbarTrackSizes.e * containerSize.e / scrollerSize.e, config.scrollHandleMinSize )), f: Math.round(Math.max( scrollbarTrackSizes.f * containerSize.f / scrollerSize.f, config.scrollHandleMinSize )) }; scrollbars.handles.e.style.width = scrollbars.sizes.e + "px"; scrollbars.handles.f.style.height = scrollbars.sizes.f + "px"; // maximum scrollbar offsets scrollbars.maxOffsets = { e: scrollbarTrackSizes.e - scrollbars.handles.e.offsetWidth, f: scrollbarTrackSizes.f - scrollbars.handles.f.offsetHeight }; // calculate offset ratios // (scroller.offset * offsetratio = scrollhandle.offset) scrollbars.offsetRatios = { e: scrolls.e ? (scrollbarTrackSizes.e - scrollbars.handles.e.offsetWidth) / scrollMin.e : 0, f: scrolls.f ? (scrollbarTrackSizes.f - scrollbars.handles.f.offsetHeight) / scrollMin.f : 0 }; }, // Standard DOM Level 2 event handler handleEvent: function handleEvent(event){ var handlerName = this.handlerNames[event.type]; if(handlerName){ this[handlerName](event); } }, // Handles touch start events on the scroller onTouchStart: function onTouchStart(event){ if(!this._doScroll){ return; } this.__proto__.currentScroller = this; this._isScrolling = false; this._trackedEvents = []; this._determineOffset(); this._trackEvent(event); this._startEventTarget = event.target; // We track this to work around a bug in android, see below var wasAnimating = this._stopAnimations(); this._snapBack(null, 0); this._startEvent = event; event.stopPropagation(); /* If the scroller was animating, prevent the default action of the event. This prevents clickable elements to be activated accidentally. Also, we need to cancel the touchstart event to prevent android from queuing up move events and fire them only when the touch ends. */ //if(wasAnimating){ event.preventDefault(); //} }, // Handles touch move events on the scroller onTouchMove: function onTouchMove(event){ if(!this._doScroll){ return; } // must be present, because touchstart fired before var lastEventOffset = this._trackedEvents[1].matrix, scrollOffset = getMatrixFromEvent(event).translate( -lastEventOffset.e, -lastEventOffset.f, 0 ), isScrolling = this._isScrolling, doScroll = isScrolling; event.stopPropagation(); event.preventDefault(); if(!doScroll){ var threshold = config.threshold, doScroll = scrollOffset.e <= -threshold || scrollOffset.e >= threshold || scrollOffset.f <= -threshold || scrollOffset.f >= threshold; } if(doScroll){ if(!isScrolling){ this._isScrolling = true; this.showScrollbars(); } this._scrollBy(scrollOffset); this._trackEvent(event); } }, onTouchEnd: function onTouchEnd(event){ var startTarget = this._startEventTarget; if(!this._isScrolling && startTarget == event.target){ /* If no scroll has been made, the touchend event should trigger a focus and a click (if occurring on the same node as the touchstart event). Unfortunately, we've canceled the touchstart event to work around a bug in android -- so we need to dispatch our own focus and click events. */ var node = event.target; while(node.nodeType != 1){ node = node.parentNode; } var focusEvent = document.createEvent("HTMLEvents"); focusEvent.initEvent("focus", false, false); node.dispatchEvent(focusEvent); //node.focus(); var clickEvent = document.createEvent("MouseEvent"); clickEvent.initMouseEvent( "click", //type true, //canBubble true, //cancelable event.view, 1, //detail (number of clicks for mouse events) event.screenX, event.screenY, event.clientX, event.clientY, event.ctrlKey, event.altKey, event.shiftKey, event.metaKey, event.button, null// relatedTarget ); node.dispatchEvent(clickEvent); this.hideScrollbars(); }else if(this._isScrolling){ var moveSpec = this._getLastMove(); if(moveSpec.duration <= config.flicking.triggerThreshold && moveSpec.length){ /* If the time between the touchend event and the last tracked event is below threshold, we are triggering a flick. */ var flickDuration = this._getFlickingDuration(moveSpec.speed), flickLength = this._getFlickingLength(moveSpec.speed, flickDuration), flickVector = moveSpec.matrix, factor = flickLength / moveSpec.length; flickVector.e *= factor; flickVector.f *= factor; this.startFlick(flickVector, flickDuration); } } if(!(this.isAnimating())){ if(this.snapToGrid){ this._snapBackToGrid(); }else{ var snappingBack = this._snapBack(); if(!snappingBack){ this.hideScrollbars(); } } } delete this._startEventTarget; this._isScrolling = false; this.__proto__.currentScroller = null; }, onTransitionEnd: function onTransitionEnd(event){ ["e", "f"].forEach(function(axis){ if(event.target === this.scrollers[axis]){ this._flicking[axis] = false; } }, this); if(!this.isAnimating()){ this.hideScrollbars(); } }, isAnimating: function isAnimating(){ var timeouts = this._animationTimeouts; var hasTimeouts = timeouts.e.length > 0 || timeouts.f.length > 0; var isFlicking = this._flicking.e || this._flicking.f; return hasTimeouts || isFlicking; }, scrollBy: function scrollBy(/*Number*/x, /*Number*/y){ this._stopAnimations(); var matrix = new WebKitCSSMatrix(); matrix.e = -x; matrix.f = -y; return this._scrollBy(matrix); }, scrollTo: function scrollTo(x, y){ this._stopAnimations(); var scrollMin = this._scrollMin; var m = new WebKitCSSMatrix(); m.e = Math.min(0, Math.max(scrollMin.e, -x)); m.f = Math.min(0, Math.max(scrollMin.f, -y)); var currentOffset = this._currentOffset; m.e -= currentOffset.e; m.f -= currentOffset.f; return this._scrollBy(m); }, center: function center(){ var x = -Math.round(this._scrollMin.e/2); var y = -Math.round(this._scrollMin.f/2); return this.scrollTo(x, y); }, // Scrolls the layer by applying a transform matrix to it. // // As this method is called for every touchmove event, the code is rolled // out for both axes (leading to redundancies) to get maximum performance. _scrollBy: function _scrollBy(/*WebKitCSSMatrix*/matrix){ var scrolls = this._scrolls; if(!scrolls.e){ matrix.e = 0; } if(!scrolls.f){ matrix.f = 0; } var scrollMin = this._scrollMin, currentOffset = this._currentOffset, newOffset = currentOffset.multiply(matrix), isOutOfBounds = {e: false, f: false}, // whether the new position is out of the scroller bounds scrollbarSizeSubstract = {e: 0, f: 0}; if(this.elastic){ var factor = config.elasticity.factorDrag, wasOutOfBounds = { // whether the scroller was already beyond scroll bounds e: currentOffset.e < scrollMin.e || currentOffset.e > 0, f: currentOffset.f < scrollMin.f || currentOffset.f > 0 }; if(wasOutOfBounds.e){ // if out of scroll bounds, apply the elasticity factor newOffset.e -= matrix.e * (1 - factor); } if(wasOutOfBounds.f){ newOffset.f -= matrix.f * (1 - factor); } if(newOffset.e < scrollMin.e || newOffset.e > 0){ isOutOfBounds.e = true; scrollbarSizeSubstract.e = newOffset.e >= 0 ? newOffset.e : scrollMin.e - newOffset.e; } if(newOffset.f < scrollMin.f || newOffset.f > 0){ isOutOfBounds.f = true; scrollbarSizeSubstract.f = newOffset.f >= 0 ? newOffset.f : scrollMin.f - newOffset.f; } var crossingBounds = { // whether the drag/scroll action went across scroller bounds e: (!wasOutOfBounds.e || !isOutOfBounds.e) && (isOutOfBounds.e || isOutOfBounds.e), f: (!wasOutOfBounds.f || !isOutOfBounds.f) && (isOutOfBounds.f || isOutOfBounds.f) }; if(crossingBounds.e){ /* If the drag went across scroll bounds, we need to apply a "mixed strategy": The part of the drag outside the bounds is mutliplicated by the elasticity factor. */ if(currentOffset.e > 0){ newOffset.e /= factor; }else if(newOffset.e > 0){ newOffset.e *= factor; }else if(currentOffset.e < scrollMin.e){ newOffset.e += (scrollMin.e - currentOffset.e) / factor; }else if(newOffset.e < scrollMin.e){ newOffset.e -= (scrollMin.e - newOffset.e) * factor; } } if(crossingBounds.f){ if(currentOffset.f > 0){ newOffset.f /= factor; }else if(newOffset.f > 0){ newOffset.f *= factor; }else if(currentOffset.f < scrollMin.f){ newOffset.f += (scrollMin.f - currentOffset.f) / factor; }else if(newOffset.f < scrollMin.f){ newOffset.f -= (scrollMin.f - newOffset.f) * factor; } } }else{ // Constrain scrolling to scroller bounds if(newOffset.e < scrollMin.e){ newOffset.e = scrollMin.e; }else if(newOffset.e > 0){ newOffset.e = 0; } if(newOffset.f < scrollMin.f){ newOffset.f = scrollMin.f; }else if(newOffset.f > 0){ newOffset.f = 0; } } this._currentOffset = newOffset; var offsetX = newOffset.translate(0, 0, 0), offsetY = newOffset.translate(0, 0, 0); offsetX.f = offsetY.e = 0; applyMatrixToNode(this.scrollers.e, offsetX); applyMatrixToNode(this.scrollers.f, offsetY); // scrollbar position var ratios = this._scrollbars.offsetRatios; offsetX.e *= ratios.e; offsetY.f *= ratios.f; if(isOutOfBounds.e){ this._squeezeScrollbar("e", scrollbarSizeSubstract.e, newOffset.e < 0); }else{ applyMatrixToNode(this._scrollbars.handles.e, offsetX); } if(isOutOfBounds.f){ this._squeezeScrollbar("f", scrollbarSizeSubstract.f, newOffset.f < 0); }else{ applyMatrixToNode(this._scrollbars.handles.f, offsetY); } }, // Tracks all properties needed for scrolling // We're tracking only the last two events for the moment _trackEvent: function _trackEvent(event){ var trackedEvents = this._trackedEvents; trackedEvents[0] = trackedEvents[1]; trackedEvents[1] = { matrix: getMatrixFromEvent(event), timeStamp: event.timeStamp }; }, showScrollbars: function showScrollbars(){ if(this.snapToGrid){ return; } var style = this._scrollbars.container.style; style.webkitTransitionDuration = ""; style.opacity = "1"; }, hideScrollbars: function hideScrollbars(){ var style = this._scrollbars.container.style; style.webkitTransitionDuration = "250ms"; style.opacity = "0"; }, _squeezeScrollbar: function _squeezeScrollbar(axis, substract, squeezeAtEnd, duration, timingFunc){ var scrollbars = this._scrollbars; var handleStyle = scrollbars.handles[axis].style; var defaultSize = scrollbars.sizes[axis]; var size = Math.max(defaultSize - substract, 1); var matrix = new WebKitCSSMatrix(); matrix[axis] = squeezeAtEnd ? scrollbars.maxOffsets[axis] : 0; matrix[axis == "f" ? "d" : "a"] = size / defaultSize; handleStyle.webkitTransformOrigin = squeezeAtEnd ? "100% 100%" : "0 0"; handleStyle.webkitTransitionProperty = "-webkit-transform"; handleStyle.webkitTransform = matrix; if(duration){ handleStyle.webkitTransitionDuration = duration + "ms"; handleStyle.webkitTransitionTimingFunction = timingFunc; this._animationTimeouts[axis].push(setTimeout(function(){ handleStyle.webkitTransitionDuration = ""; }, duration)); }else{ handleStyle.webkitTransitionDuration = ""; } }, _determineOffset: function _determineOffset(round){ var offsetX = getMatrixFromNode(this.scrollers.e), offsetY = getMatrixFromNode(this.scrollers.f), currentOffset = offsetX.multiply(offsetY); if(round){ roundMatrix(currentOffset); } this._currentOffset = currentOffset; }, _stopAnimations: function _stopAnimations(){ /*Boolean*/ var isAnimating = false; var scrollbars = this._scrollbars; ["e", "f"].forEach(function(axis){ this.scrollers[axis].style.webkitTransitionDuration = ""; var handle = scrollbars.handles[axis]; handle.style.webkitTransitionDuration = ""; setTransitionProperty(handle); scrollbars.tracks[axis].style.webkitBoxPack = ""; var timeouts = this._animationTimeouts[axis]; isAnimating = isAnimating || timeouts.length; timeouts.forEach(function(timeoutId){ clearTimeout(timeoutId); }); timeouts.length = 0; }, this); // if animating, we stop animations by determining the current // offset (rounding its values) and then setting those values // to the scroller by calling "scrollBy" this._determineOffset(true); this._scrollBy(new WebKitCSSMatrix()); // deleting queued bounces this._bounces.e = this._bounces.f = null; // resetting state var isFlicking = this._flicking; isFlicking.e = isFlicking.f = false; return isAnimating; }, _getLastMove: function _getLastMove(){ var trackedEvents = this._trackedEvents, event0 = trackedEvents[0], event1 = trackedEvents[1]; if(!event0){ return {duration: 0, matrix: new WebKitCSSMatrix(), length: 0, speed: 0}; } var duration = event1.timeStamp - event0.timeStamp, matrix = event1.matrix.multiply(event0.matrix.inverse()); var scrolls = this._scrolls; if (!scrolls.e) { matrix.e = 0; } if (!scrolls.f) { matrix.f = 0; } var length = Math.sqrt(matrix.e * matrix.e + matrix.f * matrix.f); return { duration: duration, // move duration in miliseconds matrix: matrix, // matrix of the move length: length, // length of the move in pixels speed: length / duration // speed of the move in miliseconds } }, // returns flicking duration in miliseconds for a given speed _getFlickingDuration: function _getFlickingDuration(pixelsPerMilisecond){ /* The duration is computed as follows: variables: m = minimum speed before stopping = config.flicking.minSpeed d = duration s = speed = pixelsPerMilisecond f = friction per milisecond = config.flicking.friction The minimum speed is computed as follows: m = s * f ^ d // as the minimum speed is given and we need the duration we // can solve the equation for d: <=> d = log(m/s) / log(f) */ var duration = Math.log( config.flicking.minSpeed / pixelsPerMilisecond ) / Math.log(config.flicking.friction); return duration > 0 ? Math.round(duration) : 0; }, _getFlickingLength: function _getFlickingLength(initialSpeed, flickDuration){ /* The amount of pixels to flick is the sum of the distance covered every milisecond of the flicking duration. Because the distance is decelerated by the friction factor, the speed at a given time t is: pixelsPerMilisecond * friction^t and the distance covered is: d = distance s = initial speed = pixelsPerMilisecond t = time = duration f = friction per milisecond = config.flicking.friction d = sum of s * f^n for n between 0 and t <=> d = s * (sum of f^n for n between 0 and t) which is a geometric series and thus can be simplified to: d = s * (1 - f^(d+1)) / (1 - f) */ var factor = (1 - Math.pow(config.flicking.friction, flickDuration + 1)) / (1 - config.flicking.friction); return initialSpeed * factor; }, startFlick: function startFlick(matrix, duration){ if(!(duration || this.snapToGrid)){ this._snapBack(); return; } duration = duration || config.snapBack.defaultTime; var epsilon = 1 / duration, // precision for bezier computations points = config.flicking.timingFunc, // control points for the animation function timingFunc = new CubicBezier(points[0], points[1], points[2], points[3]), min = this._scrollMin, currentOffset = this._currentOffset, scrollbars = this._scrollbars; roundMatrix(matrix); var scrollTarget = this._currentOffset.multiply(matrix); var scrolls = this._scrolls; if(this.snapToGrid){ var maxSegments = this.maxSegments; var currentSegments = this.currentSegment; } var animating = {e: true, f: true}; ["e", "f"].forEach(function(axis){ if(!scrolls[axis]){ animating[axis] = false; return; } var distance = matrix[axis], target = scrollTarget[axis], segmentFraction = 1; // the fraction of the flick distance until crossing scroller bounds // compute distance fraction where flicking crosses scroller bounds var minOffset = min[axis]; var maxOffset = 0; if(this.snapToGrid){ var containerLength = this.containerSize[axis]; var increment = distance > 0 ? -1 : 1; var maxSegment = maxSegments[axis]; var currentSegment = currentSegments[axis]; var flickToSegment = currentSegment + increment; if(flickToSegment < 0){ flickToSegment = 0; }else if(maxSegment < flickToSegment){ flickToSegment = maxSegment; } this.currentSegment[axis] = flickToSegment; if(flickToSegment == currentSegment || !distance){ return this._snapBack(axis, null, -currentSegment * containerLength); } maxOffset = minOffset = -flickToSegment * containerLength; } var segmentFraction, flick, bounce; if(this.snapToGrid){ flick = (distance < 0 ? minOffset : maxOffset) - currentOffset[axis]; bounce = 0; segmentFraction = flick / distance; }else{ if(target < minOffset){ segmentFraction = 1 - Math.max(Math.min((target - minOffset) / matrix[axis], 1), 0); }else if(target > maxOffset){ segmentFraction = 1 - Math.max(Math.min((target - maxOffset) / matrix[axis], 1), 0); } flick = segmentFraction * distance; bounce = distance - flick; } if(!(flick || bounce)){ animating[axis] = this._snapBack(axis); return; } var t = timingFunc.getTforY(segmentFraction, epsilon); if (t > 1) { t = 1; } else if (t < 0) { t = 0 } var timeFraction = timingFunc.getPointForT(t).x, bezierCurves = timingFunc.divideAtT(t); var flickTransform = new WebKitCSSMatrix(); flickTransform[axis] = currentOffset[axis]; var flickDuration = timeFraction * duration; if(flick && timeFraction){ this._flicking[axis] = true; // animate scroller flickTransform[axis] += flick; applyMatrixToNode(this.scrollers[axis], flickTransform, flickDuration + "ms", bezierCurves[0]); // animate scrollbars var scrollbarTransform = flickTransform.translate(0, 0, 0); scrollbarTransform[axis] *= scrollbars.offsetRatios[axis]; applyMatrixToNode(scrollbars.handles[axis], scrollbarTransform, flickDuration + "ms", bezierCurves[0]); } if(this.elastic && bounce){ var bounceTransform = flickTransform.translate(0, 0, 0), bounceTiming = bezierCurves[1]; // Creating a smooth transition from bounce out to snap back bounceTiming._p2 = { x: 1 - config.snapBack.timingFunc[0], y: 1 - config.snapBack.timingFunc[1] }; // limit the bounce to the configured maximum var bounceFactor = Math.min( config.elasticity.factorFlick, config.elasticity.max / Math.abs(bounce) ); bounceTransform[axis] += bounce * bounceFactor; var bounceDuration = (1 - timeFraction) * duration * bounceFactor; this._bounces[axis] = { timingFunc: bounceTiming, duration: bounceDuration + "ms", matrix: bounceTransform, bounceLength: Math.abs(bounce * bounceFactor) }; // play queued animations with timeouts, because // the webkitTransitionEnd event fires late on iPhone 3G var that = this; var timeouts = this._animationTimeouts[axis]; var handle = this._sc timeouts.push(setTimeout(function(){ that._playQueuedBounce(axis); }, flickDuration)); timeouts.push(setTimeout(function(){ var duration = config.snapBack.alwaysDefaultTime ? null : bounceDuration; that._snapBack(axis, duration); timeouts.length = 0; // clear timeouts }, flickDuration + bounceDuration)); } }, this); if(!(animating.e || animating.f)){ this.hideScrollbars(); } }, _playQueuedBounce: function _playQueuedBounce(axis){ var bounce = this._bounces[axis]; if(bounce){ var scroller = this.scrollers[axis], that = this, matrix = bounce.matrix, duration = bounce.duration, timingFunc = bounce.timingFunc; applyMatrixToNode(scroller, matrix, duration, timingFunc); // bounce scrollbar handle this._squeezeScrollbar(axis, bounce.bounceLength, matrix[axis] < 0, duration, timingFunc); this._bounces[axis] = null; return true; } return false; }, _snapBack: function _snapBack(/*String?*/axis, /*Number?*/duration, /*Number?*/target){ /*Boolean*/ duration = duration != null ? duration : config.snapBack.defaultTime; if(axis == null){ var snapBackE = this._snapBack("e", duration); var snapBackF = this._snapBack("f", duration); var snappingBack = snapBackE || snapBackF; if(!snappingBack){ this.hideScrollbars(); }else{ var scroller = this; this._animationTimeouts.f.push(setTimeout(function(){ scroller.hideScrollbars(); }, duration)); } return snappingBack; } var scroller = this.scrollers[axis], offset = getMatrixFromNode(scroller), cp = config.snapBack.timingFunc, // control points timingFunc = new CubicBezier(cp[0], cp[1], cp[2], cp[3]); if(target != null || offset[axis] < this._scrollMin[axis] || offset[axis] > 0){ offset[axis] = target != null ? target : Math.max(Math.min(offset[axis], 0), this._scrollMin[axis]); this._squeezeScrollbar(axis, 0, offset[axis] < 0, duration, timingFunc); applyMatrixToNode(scroller, offset, duration + "ms", timingFunc); return Boolean(duration); } return false; }, _snapBackToGrid: function snapBackToGrid(){ var currentOffset = this._currentOffset; var containerSize = this.containerSize; ["e", "f"].forEach(function(axis){ var axisOffset = currentOffset[axis]; var containerLength = containerSize[axis]; var currentSegment = -Math.floor((axisOffset + 0.5 * containerLength )/containerLength); var maxSegment = this.maxSegments[axis]; if(currentSegment < 0){ currentSegment = 0; }else if(maxSegment < currentSegment){ currentSegment = maxSegment; } this.currentSegment[axis] = currentSegment; return this._snapBack(axis, null, -currentSegment * containerLength); }, this); } }; return TouchScroll; }());