/*
* Zynga Scroller for jQuery
*
* An easy way to add the great Zynga Scroller to any jQuery project.
*
* This port only utilizes the scrolling features, and doesn't
* include zooming or pull to refresh. Although, we do have the
* option to set a default offset for our scrollable areas, so
* that's kind of a bonus.
*
* Plus, the minified version is only 15k!
*
* Porting to jQuery by Matt Hoiland on August 2nd, 2012
* http://www.matthoiland.com/
*/
$.fn.scroller = function(options){
// Default Settings
var settings = $.extend( {
'scrollingX' : 'true',
'scrollingY' : 'true',
'offsetTop' : 0,
'offsetLeft' : 0
}, options);
// Add empty data-init to element
$(this).attr('data-init','');
// Attach EasyScroller to the Element
new EasyScroller($(this)[0], settings);
return this;
};
/*
* Below are the following files from the original Zynga build:
* 1. Animate.js
* 2. Scroller.js - [ edited on lines 494-510 ]
* 3. EasyScroller.js - [ edited on lines 1657-1663 and 1728-1735 ]
*/
/*
* Scroller
* http://github.com/zynga/scroller
*
* Copyright 2011, Zynga Inc.
* Licensed under the MIT License.
* https://raw.github.com/zynga/scroller/master/MIT-LICENSE.txt
*
* Based on the work of: Unify Project (unify-project.org)
* http://unify-project.org
* Copyright 2011, Deutsche Telekom AG
* License: MIT + Apache (V2)
*/
/**
* Generic animation class with support for dropped frames both optional easing and duration.
*
* Optional duration is useful when the lifetime is defined by another condition than time
* e.g. speed of an animating object, etc.
*
* Dropped frame logic allows to keep using the same updater logic independent from the actual
* rendering. This eases a lot of cases where it might be pretty complex to break down a state
* based on the pure time difference.
*/
(function(global) {
var time = Date.now || function() {
return +new Date();
};
var desiredFrames = 60;
var millisecondsPerSecond = 1000;
var running = {};
var counter = 1;
// Create namespaces
if (!global.core) {
global.core = { effect : {} };
} else if (!core.effect) {
core.effect = {};
}
core.effect.Animate = {
/**
* A requestAnimationFrame wrapper / polyfill.
*
* @param callback {Function} The callback to be invoked before the next repaint.
* @param root {HTMLElement} The root element for the repaint
*/
requestAnimationFrame: (function() {
// Check for request animation Frame support
var requestFrame = global.requestAnimationFrame || global.webkitRequestAnimationFrame || global.mozRequestAnimationFrame || global.oRequestAnimationFrame;
var isNative = !!requestFrame;
if (requestFrame && !/requestAnimationFrame\(\)\s*\{\s*\[native code\]\s*\}/i.test(requestFrame.toString())) {
isNative = false;
}
if (isNative) {
return function(callback, root) {
requestFrame(callback, root)
};
}
var TARGET_FPS = 60;
var requests = {};
var requestCount = 0;
var rafHandle = 1;
var intervalHandle = null;
var lastActive = +new Date();
return function(callback, root) {
var callbackHandle = rafHandle++;
// Store callback
requests[callbackHandle] = callback;
requestCount++;
// Create timeout at first request
if (intervalHandle === null) {
intervalHandle = setInterval(function() {
var time = +new Date();
var currentRequests = requests;
// Reset data structure before executing callbacks
requests = {};
requestCount = 0;
for(var key in currentRequests) {
if (currentRequests.hasOwnProperty(key)) {
currentRequests[key](time);
lastActive = time;
}
}
// Disable the timeout when nothing happens for a certain
// period of time
if (time - lastActive > 2500) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}, 1000 / TARGET_FPS);
}
return callbackHandle;
};
})(),
/**
* Stops the given animation.
*
* @param id {Integer} Unique animation ID
* @return {Boolean} Whether the animation was stopped (aka, was running before)
*/
stop: function(id) {
var cleared = running[id] != null;
if (cleared) {
running[id] = null;
}
return cleared;
},
/**
* Whether the given animation is still running.
*
* @param id {Integer} Unique animation ID
* @return {Boolean} Whether the animation is still running
*/
isRunning: function(id) {
return running[id] != null;
},
/**
* Start the animation.
*
* @param stepCallback {Function} Pointer to function which is executed on every step.
* Signature of the method should be `function(percent, now, virtual) { return continueWithAnimation; }`
* @param verifyCallback {Function} Executed before every animation step.
* Signature of the method should be `function() { return continueWithAnimation; }`
* @param completedCallback {Function}
* Signature of the method should be `function(droppedFrames, finishedAnimation) {}`
* @param duration {Integer} Milliseconds to run the animation
* @param easingMethod {Function} Pointer to easing function
* Signature of the method should be `function(percent) { return modifiedValue; }`
* @param root {Element ? document.body} Render root, when available. Used for internal
* usage of requestAnimationFrame.
* @return {Integer} Identifier of animation. Can be used to stop it any time.
*/
start: function(stepCallback, verifyCallback, completedCallback, duration, easingMethod, root) {
var start = time();
var lastFrame = start;
var percent = 0;
var dropCounter = 0;
var id = counter++;
if (!root) {
root = document.body;
}
// Compacting running db automatically every few new animations
if (id % 20 === 0) {
var newRunning = {};
for (var usedId in running) {
newRunning[usedId] = true;
}
running = newRunning;
}
// This is the internal step method which is called every few milliseconds
var step = function(virtual) {
// Normalize virtual value
var render = virtual !== true;
// Get current time
var now = time();
// Verification is executed before next animation step
if (!running[id] || (verifyCallback && !verifyCallback(id))) {
running[id] = null;
completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, false);
return;
}
// For the current rendering to apply let's update omitted steps in memory.
// This is important to bring internal state variables up-to-date with progress in time.
if (render) {
var droppedFrames = Math.round((now - lastFrame) / (millisecondsPerSecond / desiredFrames)) - 1;
for (var j = 0; j < Math.min(droppedFrames, 4); j++) {
step(true);
dropCounter++;
}
}
// Compute percent value
if (duration) {
percent = (now - start) / duration;
if (percent > 1) {
percent = 1;
}
}
// Execute step callback, then...
var value = easingMethod ? easingMethod(percent) : percent;
if ((stepCallback(value, now, render) === false || percent === 1) && render) {
running[id] = null;
completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, percent === 1 || duration == null);
} else if (render) {
lastFrame = now;
core.effect.Animate.requestAnimationFrame(step, root);
}
};
// Mark as running
running[id] = true;
// Init first step
core.effect.Animate.requestAnimationFrame(step, root);
// Return unique animation ID
return id;
}
};
})(this);
/*
* Scroller
* http://github.com/zynga/scroller
*
* Copyright 2011, Zynga Inc.
* Licensed under the MIT License.
* https://raw.github.com/zynga/scroller/master/MIT-LICENSE.txt
*
* Based on the work of: Unify Project (unify-project.org)
* http://unify-project.org
* Copyright 2011, Deutsche Telekom AG
* License: MIT + Apache (V2)
*/
var Scroller;
(function() {
/**
* A pure logic 'component' for 'virtual' scrolling/zooming.
*/
Scroller = function(callback, options) {
this.__callback = callback;
this.options = {
/** Enable scrolling on x-axis */
scrollingX: true,
/** Enable scrolling on y-axis */
scrollingY: true,
/** Enable animations for deceleration, snap back, zooming and scrolling */
animating: true,
/** duration for animations triggered by scrollTo/zoomTo */
animationDuration: 250,
/** Enable bouncing (content can be slowly moved outside and jumps back after releasing) */
bouncing: true,
/** Enable locking to the main axis if user moves only slightly on one of them at start */
locking: true,
/** Enable pagination mode (switching between full page content panes) */
paging: false,
/** Enable snapping of content to a configured pixel grid */
snapping: false,
/** Enable zooming of content via API, fingers and mouse wheel */
zooming: false,
/** Minimum zoom level */
minZoom: 0.5,
/** Maximum zoom level */
maxZoom: 3
};
for (var key in options) {
this.options[key] = options[key];
}
};
// Easing Equations (c) 2003 Robert Penner, all rights reserved.
// Open source under the BSD License.
/**
* @param pos {Number} position between 0 (start of effect) and 1 (end of effect)
**/
var easeOutCubic = function(pos) {
return (Math.pow((pos - 1), 3) + 1);
};
/**
* @param pos {Number} position between 0 (start of effect) and 1 (end of effect)
**/
var easeInOutCubic = function(pos) {
if ((pos /= 0.5) < 1) {
return 0.5 * Math.pow(pos, 3);
}
return 0.5 * (Math.pow((pos - 2), 3) + 2);
};
var members = {
/*
---------------------------------------------------------------------------
INTERNAL FIELDS :: STATUS
---------------------------------------------------------------------------
*/
/** {Boolean} Whether only a single finger is used in touch handling */
__isSingleTouch: false,
/** {Boolean} Whether a touch event sequence is in progress */
__isTracking: false,
/**
* {Boolean} Whether a gesture zoom/rotate event is in progress. Activates when
* a gesturestart event happens. This has higher priority than dragging.
*/
__isGesturing: false,
/**
* {Boolean} Whether the user has moved by such a distance that we have enabled
* dragging mode. Hint: It's only enabled after some pixels of movement to
* not interrupt with clicks etc.
*/
__isDragging: false,
/**
* {Boolean} Not touching and dragging anymore, and smoothly animating the
* touch sequence using deceleration.
*/
__isDecelerating: false,
/**
* {Boolean} Smoothly animating the currently configured change
*/
__isAnimating: false,
/*
---------------------------------------------------------------------------
INTERNAL FIELDS :: DIMENSIONS
---------------------------------------------------------------------------
*/
/** {Integer} Available outer left position (from document perspective) */
__clientLeft: 0,
/** {Integer} Available outer top position (from document perspective) */
__clientTop: 0,
/** {Integer} Available outer width */
__clientWidth: 0,
/** {Integer} Available outer height */
__clientHeight: 0,
/** {Integer} Outer width of content */
__contentWidth: 0,
/** {Integer} Outer height of content */
__contentHeight: 0,
/** {Integer} Snapping width for content */
__snapWidth: 100,
/** {Integer} Snapping height for content */
__snapHeight: 100,
/** {Integer} Height to assign to refresh area */
__refreshHeight: null,
/** {Boolean} Whether the refresh process is enabled when the event is released now */
__refreshActive: false,
/** {Function} Callback to execute on activation. This is for signalling the user about a refresh is about to happen when he release */
__refreshActivate: null,
/** {Function} Callback to execute on deactivation. This is for signalling the user about the refresh being cancelled */
__refreshDeactivate: null,
/** {Function} Callback to execute to start the actual refresh. Call {@link #refreshFinish} when done */
__refreshStart: null,
/** {Number} Zoom level */
__zoomLevel: 1,
/* ***************************************************************/
// Edited by Matt Hoiland for jQuery
setLeftTopOffset: function(offsetLeft, offsetTop) {
var self = this;
self.__scrollLeft = offsetLeft;
self.__scrollTop = offsetTop;
},
/** {Number} Scroll position on x-axis */
//__scrollLeft: 500,
/** {Number} Scroll position on y-axis */
//__scrollTop: 0,
/* ***************************************************************/
/** {Integer} Maximum allowed scroll position on x-axis */
__maxScrollLeft: 0,
/** {Integer} Maximum allowed scroll position on y-axis */
__maxScrollTop: 0,
/* {Number} Scheduled left position (final position when animating) */
__scheduledLeft: 0,
/* {Number} Scheduled top position (final position when animating) */
__scheduledTop: 0,
/* {Number} Scheduled zoom level (final scale when animating) */
__scheduledZoom: 0,
/*
---------------------------------------------------------------------------
INTERNAL FIELDS :: LAST POSITIONS
---------------------------------------------------------------------------
*/
/** {Number} Left position of finger at start */
__lastTouchLeft: null,
/** {Number} Top position of finger at start */
__lastTouchTop: null,
/** {Date} Timestamp of last move of finger. Used to limit tracking range for deceleration speed. */
__lastTouchMove: null,
/** {Array} List of positions, uses three indexes for each state: left, top, timestamp */
__positions: null,
/*
---------------------------------------------------------------------------
INTERNAL FIELDS :: DECELERATION SUPPORT
---------------------------------------------------------------------------
*/
/** {Integer} Minimum left scroll position during deceleration */
__minDecelerationScrollLeft: null,
/** {Integer} Minimum top scroll position during deceleration */
__minDecelerationScrollTop: null,
/** {Integer} Maximum left scroll position during deceleration */
__maxDecelerationScrollLeft: null,
/** {Integer} Maximum top scroll position during deceleration */
__maxDecelerationScrollTop: null,
/** {Number} Current factor to modify horizontal scroll position with on every step */
__decelerationVelocityX: null,
/** {Number} Current factor to modify vertical scroll position with on every step */
__decelerationVelocityY: null,
/*
---------------------------------------------------------------------------
PUBLIC API
---------------------------------------------------------------------------
*/
/**
* Configures the dimensions of the client (outer) and content (inner) elements.
* Requires the available space for the outer element and the outer size of the inner element.
* All values which are falsy (null or zero etc.) are ignored and the old value is kept.
*
* @param clientWidth {Integer ? null} Inner width of outer element
* @param clientHeight {Integer ? null} Inner height of outer element
* @param contentWidth {Integer ? null} Outer width of inner element
* @param contentHeight {Integer ? null} Outer height of inner element
*/
setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight) {
var self = this;
// Only update values which are defined
if (clientWidth) {
self.__clientWidth = clientWidth;
}
if (clientHeight) {
self.__clientHeight = clientHeight;
}
if (contentWidth) {
self.__contentWidth = contentWidth;
}
if (contentHeight) {
self.__contentHeight = contentHeight;
}
// Refresh maximums
self.__computeScrollMax();
// Refresh scroll position
self.scrollTo(self.__scrollLeft, self.__scrollTop, true);
},
/**
* Sets the client coordinates in relation to the document.
*
* @param left {Integer ? 0} Left position of outer element
* @param top {Integer ? 0} Top position of outer element
*/
setPosition: function(left, top) {
var self = this;
self.__clientLeft = left || 0;
self.__clientTop = top || 0;
},
/**
* Configures the snapping (when snapping is active)
*
* @param width {Integer} Snapping width
* @param height {Integer} Snapping height
*/
setSnapSize: function(width, height) {
var self = this;
self.__snapWidth = width;
self.__snapHeight = height;
},
/**
* Activates pull-to-refresh. A special zone on the top of the list to start a list refresh whenever
* the user event is released during visibility of this zone. This was introduced by some apps on iOS like
* the official Twitter client.
*
* @param height {Integer} Height of pull-to-refresh zone on top of rendered list
* @param activateCallback {Function} Callback to execute on activation. This is for signalling the user about a refresh is about to happen when he release.
* @param deactivateCallback {Function} Callback to execute on deactivation. This is for signalling the user about the refresh being cancelled.
* @param startCallback {Function} Callback to execute to start the real async refresh action. Call {@link #finishPullToRefresh} after finish of refresh.
*/
activatePullToRefresh: function(height, activateCallback, deactivateCallback, startCallback) {
var self = this;
self.__refreshHeight = height;
self.__refreshActivate = activateCallback;
self.__refreshDeactivate = deactivateCallback;
self.__refreshStart = startCallback;
},
/**
* Signalizes that pull-to-refresh is finished.
*/
finishPullToRefresh: function() {
var self = this;
self.__refreshActive = false;
if (self.__refreshDeactivate) {
self.__refreshDeactivate();
}
self.scrollTo(self.__scrollLeft, self.__scrollTop, true);
},
/**
* Returns the scroll position and zooming values
*
* @return {Map} `left` and `top` scroll position and `zoom` level
*/
getValues: function() {
var self = this;
return {
left: self.__scrollLeft,
top: self.__scrollTop,
zoom: self.__zoomLevel
};
},
/**
* Returns the maximum scroll values
*
* @return {Map} `left` and `top` maximum scroll values
*/
getScrollMax: function() {
var self = this;
return {
left: self.__maxScrollLeft,
top: self.__maxScrollTop
};
},
/**
* Zooms to the given level. Supports optional animation. Zooms
* the center when no coordinates are given.
*
* @param level {Number} Level to zoom to
* @param animate {Boolean ? false} Whether to use animation
* @param originLeft {Number ? null} Zoom in at given left coordinate
* @param originTop {Number ? null} Zoom in at given top coordinate
*/
zoomTo: function(level, animate, originLeft, originTop) {
var self = this;
if (!self.options.zooming) {
throw new Error("Zooming is not enabled!");
}
// Stop deceleration
if (self.__isDecelerating) {
core.effect.Animate.stop(self.__isDecelerating);
self.__isDecelerating = false;
}
var oldLevel = self.__zoomLevel;
// Normalize input origin to center of viewport if not defined
if (originLeft == null) {
originLeft = self.__clientWidth / 2;
}
if (originTop == null) {
originTop = self.__clientHeight / 2;
}
// Limit level according to configuration
level = Math.max(Math.min(level, self.options.maxZoom), self.options.minZoom);
// Recompute maximum values while temporary tweaking maximum scroll ranges
self.__computeScrollMax(level);
// Recompute left and top coordinates based on new zoom level
var left = ((originLeft + self.__scrollLeft) * level / oldLevel) - originLeft;
var top = ((originTop + self.__scrollTop) * level / oldLevel) - originTop;
// Limit x-axis
if (left > self.__maxScrollLeft) {
left = self.__maxScrollLeft;
} else if (left < 0) {
left = 0;
}
// Limit y-axis
if (top > self.__maxScrollTop) {
top = self.__maxScrollTop;
} else if (top < 0) {
top = 0;
}
// Push values out
self.__publish(left, top, level, animate);
},
/**
* Zooms the content by the given factor.
*
* @param factor {Number} Zoom by given factor
* @param animate {Boolean ? false} Whether to use animation
* @param originLeft {Number ? 0} Zoom in at given left coordinate
* @param originTop {Number ? 0} Zoom in at given top coordinate
*/
zoomBy: function(factor, animate, originLeft, originTop) {
var self = this;
self.zoomTo(self.__zoomLevel * factor, animate, originLeft, originTop);
},
/**
* Scrolls to the given position. Respect limitations and snapping automatically.
*
* @param left {Number?null} Horizontal scroll position, keeps current if value is null
* @param top {Number?null} Vertical scroll position, keeps current if value is null
* @param animate {Boolean?false} Whether the scrolling should happen using an animation
* @param zoom {Number?null} Zoom level to go to
*/
scrollTo: function(left, top, animate, zoom) {
var self = this;
// Stop deceleration
if (self.__isDecelerating) {
core.effect.Animate.stop(self.__isDecelerating);
self.__isDecelerating = false;
}
// Correct coordinates based on new zoom level
if (zoom != null && zoom !== self.__zoomLevel) {
if (!self.options.zooming) {
throw new Error("Zooming is not enabled!");
}
left *= zoom;
top *= zoom;
// Recompute maximum values while temporary tweaking maximum scroll ranges
self.__computeScrollMax(zoom);
} else {
// Keep zoom when not defined
zoom = self.__zoomLevel;
}
if (!self.options.scrollingX) {
left = self.__scrollLeft;
} else {
if (self.options.paging) {
left = Math.round(left / self.__clientWidth) * self.__clientWidth;
} else if (self.options.snapping) {
left = Math.round(left / self.__snapWidth) * self.__snapWidth;
}
}
if (!self.options.scrollingY) {
top = self.__scrollTop;
} else {
if (self.options.paging) {
top = Math.round(top / self.__clientHeight) * self.__clientHeight;
} else if (self.options.snapping) {
top = Math.round(top / self.__snapHeight) * self.__snapHeight;
}
}
// Limit for allowed ranges
left = Math.max(Math.min(self.__maxScrollLeft, left), 0);
top = Math.max(Math.min(self.__maxScrollTop, top), 0);
// Don't animate when no change detected, still call publish to make sure
// that rendered position is really in-sync with internal data
if (left === self.__scrollLeft && top === self.__scrollTop) {
animate = false;
}
// Publish new values
self.__publish(left, top, zoom, animate);
},
/**
* Scroll by the given offset
*
* @param left {Number ? 0} Scroll x-axis by given offset
* @param top {Number ? 0} Scroll x-axis by given offset
* @param animate {Boolean ? false} Whether to animate the given change
*/
scrollBy: function(left, top, animate) {
var self = this;
var startLeft = self.__isAnimating ? self.__scheduledLeft : self.__scrollLeft;
var startTop = self.__isAnimating ? self.__scheduledTop : self.__scrollTop;
self.scrollTo(startLeft + (left || 0), startTop + (top || 0), animate);
},
/*
---------------------------------------------------------------------------
EVENT CALLBACKS
---------------------------------------------------------------------------
*/
/**
* Mouse wheel handler for zooming support
*/
doMouseZoom: function(wheelDelta, timeStamp, pageX, pageY) {
var self = this;
var change = wheelDelta > 0 ? 0.97 : 1.03;
return self.zoomTo(self.__zoomLevel * change, false, pageX - self.__clientLeft, pageY - self.__clientTop);
},
/**
* Touch start handler for scrolling support
*/
doTouchStart: function(touches, timeStamp) {
// Array-like check is enough here
if (touches.length == null) {
throw new Error("Invalid touch list: " + touches);
}
if (timeStamp instanceof Date) {
timeStamp = timeStamp.valueOf();
}
if (typeof timeStamp !== "number") {
throw new Error("Invalid timestamp value: " + timeStamp);
}
var self = this;
// Stop deceleration
if (self.__isDecelerating) {
core.effect.Animate.stop(self.__isDecelerating);
self.__isDecelerating = false;
}
// Stop animation
if (self.__isAnimating) {
core.effect.Animate.stop(self.__isAnimating);
self.__isAnimating = false;
}
// Use center point when dealing with two fingers
var currentTouchLeft, currentTouchTop;
var isSingleTouch = touches.length === 1;
if (isSingleTouch) {
currentTouchLeft = touches[0].pageX;
currentTouchTop = touches[0].pageY;
} else {
currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2;
currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2;
}
// Store initial positions
self.__initialTouchLeft = currentTouchLeft;
self.__initialTouchTop = currentTouchTop;
// Store current zoom level
self.__zoomLevelStart = self.__zoomLevel;
// Store initial touch positions
self.__lastTouchLeft = currentTouchLeft;
self.__lastTouchTop = currentTouchTop;
// Store initial move time stamp
self.__lastTouchMove = timeStamp;
// Reset initial scale
self.__lastScale = 1;
// Reset locking flags
self.__enableScrollX = !isSingleTouch && self.options.scrollingX;
self.__enableScrollY = !isSingleTouch && self.options.scrollingY;
// Reset tracking flag
self.__isTracking = true;
// Dragging starts directly with two fingers, otherwise lazy with an offset
self.__isDragging = !isSingleTouch;
// Some features are disabled in multi touch scenarios
self.__isSingleTouch = isSingleTouch;
// Clearing data structure
self.__positions = [];
},
/**
* Touch move handler for scrolling support
*/
doTouchMove: function(touches, timeStamp, scale) {
// Array-like check is enough here
if (touches.length == null) {
throw new Error("Invalid touch list: " + touches);
}
if (timeStamp instanceof Date) {
timeStamp = timeStamp.valueOf();
}
if (typeof timeStamp !== "number") {
throw new Error("Invalid timestamp value: " + timeStamp);
}
var self = this;
// Ignore event when tracking is not enabled (event might be outside of element)
if (!self.__isTracking) {
return;
}
var currentTouchLeft, currentTouchTop;
// Compute move based around of center of fingers
if (touches.length === 2) {
currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2;
currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2;
} else {
currentTouchLeft = touches[0].pageX;
currentTouchTop = touches[0].pageY;
}
var positions = self.__positions;
// Are we already is dragging mode?
if (self.__isDragging) {
// Compute move distance
var moveX = currentTouchLeft - self.__lastTouchLeft;
var moveY = currentTouchTop - self.__lastTouchTop;
// Read previous scroll position and zooming
var scrollLeft = self.__scrollLeft;
var scrollTop = self.__scrollTop;
var level = self.__zoomLevel;
// Work with scaling
if (scale != null && self.options.zooming) {
var oldLevel = level;
// Recompute level based on previous scale and new scale
level = level / self.__lastScale * scale;
// Limit level according to configuration
level = Math.max(Math.min(level, self.options.maxZoom), self.options.minZoom);
// Only do further compution when change happened
if (oldLevel !== level) {
// Compute relative event position to container
var currentTouchLeftRel = currentTouchLeft - self.__clientLeft;
var currentTouchTopRel = currentTouchTop - self.__clientTop;
// Recompute left and top coordinates based on new zoom level
scrollLeft = ((currentTouchLeftRel + scrollLeft) * level / oldLevel) - currentTouchLeftRel;
scrollTop = ((currentTouchTopRel + scrollTop) * level / oldLevel) - currentTouchTopRel;
// Recompute max scroll values
self.__computeScrollMax(level);
}
}
if (self.__enableScrollX) {
scrollLeft -= moveX;
var maxScrollLeft = self.__maxScrollLeft;
if (scrollLeft > maxScrollLeft || scrollLeft < 0) {
// Slow down on the edges
if (self.options.bouncing) {
scrollLeft += (moveX / 2);
} else if (scrollLeft > maxScrollLeft) {
scrollLeft = maxScrollLeft;
} else {
scrollLeft = 0;
}
}
}
// Compute new vertical scroll position
if (self.__enableScrollY) {
scrollTop -= moveY;
var maxScrollTop = self.__maxScrollTop;
if (scrollTop > maxScrollTop || scrollTop < 0) {
// Slow down on the edges
if (self.options.bouncing) {
scrollTop += (moveY / 2);
// Support pull-to-refresh (only when only y is scrollable)
if (!self.__enableScrollX && self.__refreshHeight != null) {
if (!self.__refreshActive && scrollTop <= -self.__refreshHeight) {
self.__refreshActive = true;
if (self.__refreshActivate) {
self.__refreshActivate();
}
} else if (self.__refreshActive && scrollTop > -self.__refreshHeight) {
self.__refreshActive = false;
if (self.__refreshDeactivate) {
self.__refreshDeactivate();
}
}
}
} else if (scrollTop > maxScrollTop) {
scrollTop = maxScrollTop;
} else {
scrollTop = 0;
}
}
}
// Keep list from growing infinitely (holding min 10, max 20 measure points)
if (positions.length > 60) {
positions.splice(0, 30);
}
// Track scroll movement for decleration
positions.push(scrollLeft, scrollTop, timeStamp);
// Sync scroll position
self.__publish(scrollLeft, scrollTop, level);
// Otherwise figure out whether we are switching into dragging mode now.
} else {
var minimumTrackingForScroll = self.options.locking ? 3 : 0;
var minimumTrackingForDrag = 5;
var distanceX = Math.abs(currentTouchLeft - self.__initialTouchLeft);
var distanceY = Math.abs(currentTouchTop - self.__initialTouchTop);
self.__enableScrollX = self.options.scrollingX && distanceX >= minimumTrackingForScroll;
self.__enableScrollY = self.options.scrollingY && distanceY >= minimumTrackingForScroll;
positions.push(self.__scrollLeft, self.__scrollTop, timeStamp);
self.__isDragging = (self.__enableScrollX || self.__enableScrollY) && (distanceX >= minimumTrackingForDrag || distanceY >= minimumTrackingForDrag);
}
// Update last touch positions and time stamp for next event
self.__lastTouchLeft = currentTouchLeft;
self.__lastTouchTop = currentTouchTop;
self.__lastTouchMove = timeStamp;
self.__lastScale = scale;
},
/**
* Touch end handler for scrolling support
*/
doTouchEnd: function(timeStamp) {
if (timeStamp instanceof Date) {
timeStamp = timeStamp.valueOf();
}
if (typeof timeStamp !== "number") {
throw new Error("Invalid timestamp value: " + timeStamp);
}
var self = this;
// Ignore event when tracking is not enabled (no touchstart event on element)
// This is required as this listener ('touchmove') sits on the document and not on the element itself.
if (!self.__isTracking) {
return;
}
// Not touching anymore (when two finger hit the screen there are two touch end events)
self.__isTracking = false;
// Be sure to reset the dragging flag now. Here we also detect whether
// the finger has moved fast enough to switch into a deceleration animation.
if (self.__isDragging) {
// Reset dragging flag
self.__isDragging = false;
// Start deceleration
// Verify that the last move detected was in some relevant time frame
if (self.__isSingleTouch && self.options.animating && (timeStamp - self.__lastTouchMove) <= 100) {
// Then figure out what the scroll position was about 100ms ago
var positions = self.__positions;
var endPos = positions.length - 1;
var startPos = endPos;
// Move pointer to position measured 100ms ago
for (var i = endPos; i > 0 && positions[i] > (self.__lastTouchMove - 100); i -= 3) {
startPos = i;
}
// If start and stop position is identical in a 100ms timeframe,
// we cannot compute any useful deceleration.
if (startPos !== endPos) {
// Compute relative movement between these two points
var timeOffset = positions[endPos] - positions[startPos];
var movedLeft = self.__scrollLeft - positions[startPos - 2];
var movedTop = self.__scrollTop - positions[startPos - 1];
// Based on 50ms compute the movement to apply for each render step
self.__decelerationVelocityX = movedLeft / timeOffset * (1000 / 60);
self.__decelerationVelocityY = movedTop / timeOffset * (1000 / 60);
// How much velocity is required to start the deceleration
var minVelocityToStartDeceleration = self.options.paging || self.options.snapping ? 4 : 1;
// Verify that we have enough velocity to start deceleration
if (Math.abs(self.__decelerationVelocityX) > minVelocityToStartDeceleration || Math.abs(self.__decelerationVelocityY) > minVelocityToStartDeceleration) {
// Deactivate pull-to-refresh when decelerating
if (!self.__refreshActive) {
self.__startDeceleration(timeStamp);
}
}
}
}
}
// If this was a slower move it is per default non decelerated, but this
// still means that we want snap back to the bounds which is done here.
// This is placed outside the condition above to improve edge case stability
// e.g. touchend fired without enabled dragging. This should normally do not
// have modified the scroll positions or even showed the scrollbars though.
if (!self.__isDecelerating) {
if (self.__refreshActive && self.__refreshStart) {
// Use publish instead of scrollTo to allow scrolling to out of boundary position
// We don't need to normalize scrollLeft, zoomLevel, etc. here because we only y-scrolling when pull-to-refresh is enabled
self.__publish(self.__scrollLeft, -self.__refreshHeight, self.__zoomLevel, true);
if (self.__refreshStart) {
self.__refreshStart();
}
} else {
self.scrollTo(self.__scrollLeft, self.__scrollTop, true, self.__zoomLevel);
// Directly signalize deactivation (nothing todo on refresh?)
if (self.__refreshActive) {
self.__refreshActive = false;
if (self.__refreshDeactivate) {
self.__refreshDeactivate();
}
}
}
}
// Fully cleanup list
self.__positions.length = 0;
},
/*
---------------------------------------------------------------------------
PRIVATE API
---------------------------------------------------------------------------
*/
/**
* Applies the scroll position to the content element
*
* @param left {Number} Left scroll position
* @param top {Number} Top scroll position
* @param animate {Boolean?false} Whether animation should be used to move to the new coordinates
*/
__publish: function(left, top, zoom, animate) {
var self = this;
// Remember whether we had an animation, then we try to continue based on the current "drive" of the animation
var wasAnimating = self.__isAnimating;
if (wasAnimating) {
core.effect.Animate.stop(wasAnimating);
self.__isAnimating = false;
}
if (animate && self.options.animating) {
// Keep scheduled positions for scrollBy/zoomBy functionality
self.__scheduledLeft = left;
self.__scheduledTop = top;
self.__scheduledZoom = zoom;
var oldLeft = self.__scrollLeft;
var oldTop = self.__scrollTop;
var oldZoom = self.__zoomLevel;
var diffLeft = left - oldLeft;
var diffTop = top - oldTop;
var diffZoom = zoom - oldZoom;
var step = function(percent, now, render) {
if (render) {
self.__scrollLeft = oldLeft + (diffLeft * percent);
self.__scrollTop = oldTop + (diffTop * percent);
self.__zoomLevel = oldZoom + (diffZoom * percent);
// Push values out
if (self.__callback) {
self.__callback(self.__scrollLeft, self.__scrollTop, self.__zoomLevel);
}
}
};
var verify = function(id) {
return self.__isAnimating === id;
};
var completed = function(renderedFramesPerSecond, animationId, wasFinished) {
if (animationId === self.__isAnimating) {
self.__isAnimating = false;
}
if (self.options.zooming) {
self.__computeScrollMax();
}
};
// When continuing based on previous animation we choose an ease-out animation instead of ease-in-out
self.__isAnimating = core.effect.Animate.start(step, verify, completed, self.options.animationDuration, wasAnimating ? easeOutCubic : easeInOutCubic);
} else {
self.__scheduledLeft = self.__scrollLeft = left;
self.__scheduledTop = self.__scrollTop = top;
self.__scheduledZoom = self.__zoomLevel = zoom;
// Push values out
if (self.__callback) {
self.__callback(left, top, zoom);
}
// Fix max scroll ranges
if (self.options.zooming) {
self.__computeScrollMax();
}
}
},
/**
* Recomputes scroll minimum values based on client dimensions and content dimensions.
*/
__computeScrollMax: function(zoomLevel) {
var self = this;
if (zoomLevel == null) {
zoomLevel = self.__zoomLevel;
}
self.__maxScrollLeft = Math.max((self.__contentWidth * zoomLevel) - self.__clientWidth, 0);
self.__maxScrollTop = Math.max((self.__contentHeight * zoomLevel) - self.__clientHeight, 0);
},
/*
---------------------------------------------------------------------------
ANIMATION (DECELERATION) SUPPORT
---------------------------------------------------------------------------
*/
/**
* Called when a touch sequence end and the speed of the finger was high enough
* to switch into deceleration mode.
*/
__startDeceleration: function(timeStamp) {
var self = this;
if (self.options.paging) {
var scrollLeft = Math.max(Math.min(self.__scrollLeft, self.__maxScrollLeft), 0);
var scrollTop = Math.max(Math.min(self.__scrollTop, self.__maxScrollTop), 0);
var clientWidth = self.__clientWidth;
var clientHeight = self.__clientHeight;
// We limit deceleration not to the min/max values of the allowed range, but to the size of the visible client area.
// Each page should have exactly the size of the client area.
self.__minDecelerationScrollLeft = Math.floor(scrollLeft / clientWidth) * clientWidth;
self.__minDecelerationScrollTop = Math.floor(scrollTop / clientHeight) * clientHeight;
self.__maxDecelerationScrollLeft = Math.ceil(scrollLeft / clientWidth) * clientWidth;
self.__maxDecelerationScrollTop = Math.ceil(scrollTop / clientHeight) * clientHeight;
} else {
self.__minDecelerationScrollLeft = 0;
self.__minDecelerationScrollTop = 0;
self.__maxDecelerationScrollLeft = self.__maxScrollLeft;
self.__maxDecelerationScrollTop = self.__maxScrollTop;
}
// Wrap class method
var step = function(percent, now, render) {
self.__stepThroughDeceleration(render);
};
// How much velocity is required to keep the deceleration running
var minVelocityToKeepDecelerating = self.options.snapping ? 4 : 0.1;
// Detect whether it's still worth to continue animating steps
// If we are already slow enough to not being user perceivable anymore, we stop the whole process here.
var verify = function() {
return Math.abs(self.__decelerationVelocityX) >= minVelocityToKeepDecelerating || Math.abs(self.__decelerationVelocityY) >= minVelocityToKeepDecelerating;
};
var completed = function(renderedFramesPerSecond, animationId, wasFinished) {
self.__isDecelerating = false;
// Animate to grid when snapping is active, otherwise just fix out-of-boundary positions
self.scrollTo(self.__scrollLeft, self.__scrollTop, self.options.snapping);
};
// Start animation and switch on flag
self.__isDecelerating = core.effect.Animate.start(step, verify, completed);
},
/**
* Called on every step of the animation
*
* @param inMemory {Boolean?false} Whether to not render the current step, but keep it in memory only. Used internally only!
*/
__stepThroughDeceleration: function(render) {
var self = this;
//
// COMPUTE NEXT SCROLL POSITION
//
// Add deceleration to scroll position
var scrollLeft = self.__scrollLeft + self.__decelerationVelocityX;
var scrollTop = self.__scrollTop + self.__decelerationVelocityY;
//
// HARD LIMIT SCROLL POSITION FOR NON BOUNCING MODE
//
if (!self.options.bouncing) {
var scrollLeftFixed = Math.max(Math.min(self.__maxDecelerationScrollLeft, scrollLeft), self.__minDecelerationScrollLeft);
if (scrollLeftFixed !== scrollLeft) {
scrollLeft = scrollLeftFixed;
self.__decelerationVelocityX = 0;
}
var scrollTopFixed = Math.max(Math.min(self.__maxDecelerationScrollTop, scrollTop), self.__minDecelerationScrollTop);
if (scrollTopFixed !== scrollTop) {
scrollTop = scrollTopFixed;
self.__decelerationVelocityY = 0;
}
}
//
// UPDATE SCROLL POSITION
//
if (render) {
self.__publish(scrollLeft, scrollTop, self.__zoomLevel);
} else {
self.__scrollLeft = scrollLeft;
self.__scrollTop = scrollTop;
}
//
// SLOW DOWN
//
// Slow down velocity on every iteration
if (!self.options.paging) {
// This is the factor applied to every iteration of the animation
// to slow down the process. This should emulate natural behavior where
// objects slow down when the initiator of the movement is removed
var frictionFactor = 0.95;
self.__decelerationVelocityX *= frictionFactor;
self.__decelerationVelocityY *= frictionFactor;
}
//
// BOUNCING SUPPORT
//
if (self.options.bouncing) {
var scrollOutsideX = 0;
var scrollOutsideY = 0;
// This configures the amount of change applied to deceleration/acceleration when reaching boundaries
var penetrationDeceleration = 0.03;
var penetrationAcceleration = 0.08;
// Check limits
if (scrollLeft < self.__minDecelerationScrollLeft) {
scrollOutsideX = self.__minDecelerationScrollLeft - scrollLeft;
} else if (scrollLeft > self.__maxDecelerationScrollLeft) {
scrollOutsideX = self.__maxDecelerationScrollLeft - scrollLeft;
}
if (scrollTop < self.__minDecelerationScrollTop) {
scrollOutsideY = self.__minDecelerationScrollTop - scrollTop;
} else if (scrollTop > self.__maxDecelerationScrollTop) {
scrollOutsideY = self.__maxDecelerationScrollTop - scrollTop;
}
// Slow down until slow enough, then flip back to snap position
if (scrollOutsideX !== 0) {
if (scrollOutsideX * self.__decelerationVelocityX <= 0) {
self.__decelerationVelocityX += scrollOutsideX * penetrationDeceleration;
} else {
self.__decelerationVelocityX = scrollOutsideX * penetrationAcceleration;
}
}
if (scrollOutsideY !== 0) {
if (scrollOutsideY * self.__decelerationVelocityY <= 0) {
self.__decelerationVelocityY += scrollOutsideY * penetrationDeceleration;
} else {
self.__decelerationVelocityY = scrollOutsideY * penetrationAcceleration;
}
}
}
}
};
// Copy over members to prototype
for (var key in members) {
Scroller.prototype[key] = members[key];
}
})();
var EasyScroller = function(content, options) {
this.content = content;
this.container = content.parentNode;
this.options = options || {};
// create Scroller instance
var that = this;
this.scroller = new Scroller(function(left, top, zoom) {
that.render(left, top, zoom);
}, options);
$(content).data('easyScroller', this);
$(content).data('scroller', this.scroller);
// bind events
this.bindEvents();
// the content element needs a correct transform origin for zooming
this.content.style[EasyScroller.vendorPrefix + 'TransformOrigin'] = "left top";
/* ***************************************************************/
// Edited by Matt Hoiland for jQuery
// reflow for the first time
this.reflow(options);
/* ***************************************************************/
};
EasyScroller.prototype.render = (function() {
// Determine Vendor Prefix
//
var docStyle = document.documentElement.style;
var engine;
if (window.opera && Object.prototype.toString.call(opera) === '[object Opera]') {
engine = 'presto';
} else if ('MozAppearance' in docStyle) {
engine = 'gecko';
} else if ('WebkitAppearance' in docStyle) {
engine = 'webkit';
} else if (typeof navigator.cpuClass === 'string') {
engine = 'trident';
}
var vendorPrefix = EasyScroller.vendorPrefix = {
trident: 'ms',
gecko: 'Moz',
webkit: 'Webkit',
presto: 'O'
}[engine];
// Create a helper div to test the browser capability
//
var helperElem = document.createElement("div");
var undef;
// Append the proper vendor prefix to the DOM style declaration
//
var perspectiveProperty = vendorPrefix + "Perspective";
var transformProperty = vendorPrefix + "Transform";
// Test if the browser can use CSS 'translate3d' or 'translate'
//
if (helperElem.style[perspectiveProperty] !== undef) {
return function(left, top, zoom) {
this.content.style[transformProperty] = 'translate3d(' + (-left) + 'px,' + (-top) + 'px,0) scale(' + zoom + ')';
};
} else if (helperElem.style[transformProperty] !== undef) {
return function(left, top, zoom) {
this.content.style[transformProperty] = 'translate(' + (-left) + 'px,' + (-top) + 'px) scale(' + zoom + ')';
};
} else {
return function(left, top, zoom) {
this.content.style.marginLeft = left ? (-left/zoom) + 'px' : '';
this.content.style.marginTop = top ? (-top/zoom) + 'px' : '';
this.content.style.zoom = zoom || '';
};
}
})();
EasyScroller.prototype.reflow = function(options) { // Argument added by Matt Hoiland [ see line 24 ]
/* ***************************************************************/
// Added by Matt Hoiland for jQuery
// Also see [ Scroller.js lines 189-193 ]
// set the offset (left, top)
this.scroller.setLeftTopOffset(options.offsetLeft,options.offsetTop);
/* ***************************************************************/
// set the right scroller dimensions
this.scroller.setDimensions(this.container.clientWidth, this.container.clientHeight, this.content.offsetWidth, this.content.offsetHeight);
// refresh the position for zooming purposes
var rect = this.container.getBoundingClientRect();
this.scroller.setPosition(rect.left + this.container.clientLeft, rect.top + this.container.clientTop);
};
EasyScroller.prototype.bindEvents = function() {
var that = this;
// reflow handling
window.addEventListener("resize", function() {
that.reflow();
}, false);
// touch devices bind touch events
if ('ontouchstart' in window) {
this.container.addEventListener("touchstart", function(e) {
// Don't react if initial down happens on a form element
if (e.touches[0] && e.touches[0].target && e.touches[0].target.tagName.match(/input|textarea|select/i)) {
return;
}
that.scroller.doTouchStart(e.touches, e.timeStamp);
e.preventDefault();
}, false);
document.addEventListener("touchmove", function(e) {
that.scroller.doTouchMove(e.touches, e.timeStamp, e.scale);
}, false);
document.addEventListener("touchend", function(e) {
that.scroller.doTouchEnd(e.timeStamp);
}, false);
document.addEventListener("touchcancel", function(e) {
that.scroller.doTouchEnd(e.timeStamp);
}, false);
// non-touch bind mouse events
} else {
var mousedown = false;
this.container.addEventListener("mousedown", function(e) {
if (e.target.tagName.match(/input|textarea|select/i)) {
return;
}
that.scroller.doTouchStart([{
pageX: e.pageX,
pageY: e.pageY
}], e.timeStamp);
mousedown = true;
e.preventDefault();
}, false);
document.addEventListener("mousemove", function(e) {
if (!mousedown) {
return;
}
that.scroller.doTouchMove([{
pageX: e.pageX,
pageY: e.pageY
}], e.timeStamp);
mousedown = true;
}, false);
document.addEventListener("mouseup", function(e) {
if (!mousedown) {
return;
}
that.scroller.doTouchEnd(e.timeStamp);
mousedown = false;
}, false);
this.container.addEventListener("mousewheel", function(e) {
if(that.options.zooming) {
that.scroller.doMouseZoom(e.wheelDelta, e.timeStamp, e.pageX, e.pageY);
e.preventDefault();
}
}, false);
}
};