mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Closes #482 Zynga Scroller slowly lowers the acceleration of scroll as soon as you pass the boundaries. Once the acceleration reaches zero, zynga will 'flip' the acceleration in the other direction and scroll back to within the boundaries (bounce effect). The problem is, sometimes as it slowly lowers the acceleration it will get *near zero*, but not reach zero. When acceleration gets close enough to zero, zynga stops the scrolling because it deems it 'too slow'. Now, the scrolling acceleration will 'flip' and go back towards being in boundaries (bounce) when the scrolling is below a certain minimum, not just when it is below zero.
2025 lines
58 KiB
JavaScript
2025 lines
58 KiB
JavaScript
/*
|
|
* 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} 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(ionic) {
|
|
var NOOP = function(){};
|
|
|
|
// 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);
|
|
};
|
|
|
|
|
|
/**
|
|
* ionic.views.Scroll
|
|
* A powerful scroll view with support for bouncing, pull to refresh, and paging.
|
|
* @param {Object} options options for the scroll view
|
|
* @class A scroll view system
|
|
* @memberof ionic.views
|
|
*/
|
|
ionic.views.Scroll = ionic.views.View.inherit({
|
|
initialize: function(options) {
|
|
var self = this;
|
|
|
|
this.__container = options.el;
|
|
this.__content = options.el.firstElementChild;
|
|
|
|
|
|
this.options = {
|
|
|
|
/** Disable scrolling on x-axis by default */
|
|
scrollingX: false,
|
|
scrollbarX: true,
|
|
|
|
/** Enable scrolling on y-axis */
|
|
scrollingY: true,
|
|
scrollbarY: true,
|
|
|
|
startX: 0,
|
|
startY: 0,
|
|
|
|
/** The minimum size the scrollbars scale to while scrolling */
|
|
minScrollbarSizeX: 5,
|
|
minScrollbarSizeY: 5,
|
|
|
|
/** Scrollbar fading after scrolling */
|
|
scrollbarsFade: true,
|
|
scrollbarFadeDelay: 300,
|
|
/** The initial fade delay when the pane is resized or initialized */
|
|
scrollbarResizeFadeDelay: 1000,
|
|
|
|
/** 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,
|
|
|
|
/** Multiply or decrease scrolling speed **/
|
|
speedMultiplier: 1,
|
|
|
|
/** Callback that is fired on the later of touch end or deceleration end,
|
|
provided that another scrolling action has not begun. Used to know
|
|
when to fade out a scrollbar. */
|
|
scrollingComplete: NOOP,
|
|
|
|
/** This configures the amount of change applied to deceleration when reaching boundaries **/
|
|
penetrationDeceleration : 0.03,
|
|
|
|
/** This configures the amount of change applied to acceleration when reaching boundaries **/
|
|
penetrationAcceleration : 0.08,
|
|
|
|
// The ms interval for triggering scroll events
|
|
scrollEventInterval: 50
|
|
};
|
|
|
|
for (var key in options) {
|
|
this.options[key] = options[key];
|
|
}
|
|
|
|
this.hintResize = ionic.debounce(function() {
|
|
self.resize();
|
|
}, 1000, true);
|
|
|
|
this.triggerScrollEvent = ionic.throttle(function() {
|
|
ionic.trigger('scroll', {
|
|
scrollTop: self.__scrollTop,
|
|
scrollLeft: self.__scrollLeft,
|
|
target: self.__container
|
|
});
|
|
}, this.options.scrollEventInterval);
|
|
|
|
this.triggerScrollEndEvent = function() {
|
|
ionic.trigger('scrollend', {
|
|
scrollTop: self.__scrollTop,
|
|
scrollLeft: self.__scrollLeft,
|
|
target: self.__container
|
|
});
|
|
};
|
|
|
|
this.__scrollLeft = this.options.startX;
|
|
this.__scrollTop = this.options.startY;
|
|
|
|
// Get the render update function, initialize event handlers,
|
|
// and calculate the size of the scroll container
|
|
this.__callback = this.getRenderFn();
|
|
this.__initEventHandlers();
|
|
this.__createScrollbars();
|
|
|
|
},
|
|
|
|
run: function() {
|
|
this.resize();
|
|
|
|
// Fade them out
|
|
this.__fadeScrollbars('out', this.options.scrollbarResizeFadeDelay);
|
|
},
|
|
|
|
|
|
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
INTERNAL FIELDS :: STATUS
|
|
---------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** Whether only a single finger is used in touch handling */
|
|
__isSingleTouch: false,
|
|
|
|
/** Whether a touch event sequence is in progress */
|
|
__isTracking: false,
|
|
|
|
/** Whether a deceleration animation went to completion. */
|
|
__didDecelerationComplete: false,
|
|
|
|
/**
|
|
* Whether a gesture zoom/rotate event is in progress. Activates when
|
|
* a gesturestart event happens. This has higher priority than dragging.
|
|
*/
|
|
__isGesturing: false,
|
|
|
|
/**
|
|
* 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,
|
|
|
|
/**
|
|
* Not touching and dragging anymore, and smoothly animating the
|
|
* touch sequence using deceleration.
|
|
*/
|
|
__isDecelerating: false,
|
|
|
|
/**
|
|
* Smoothly animating the currently configured change
|
|
*/
|
|
__isAnimating: false,
|
|
|
|
|
|
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
INTERNAL FIELDS :: DIMENSIONS
|
|
---------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** Available outer left position (from document perspective) */
|
|
__clientLeft: 0,
|
|
|
|
/** Available outer top position (from document perspective) */
|
|
__clientTop: 0,
|
|
|
|
/** Available outer width */
|
|
__clientWidth: 0,
|
|
|
|
/** Available outer height */
|
|
__clientHeight: 0,
|
|
|
|
/** Outer width of content */
|
|
__contentWidth: 0,
|
|
|
|
/** Outer height of content */
|
|
__contentHeight: 0,
|
|
|
|
/** Snapping width for content */
|
|
__snapWidth: 100,
|
|
|
|
/** Snapping height for content */
|
|
__snapHeight: 100,
|
|
|
|
/** Height to assign to refresh area */
|
|
__refreshHeight: null,
|
|
|
|
/** Whether the refresh process is enabled when the event is released now */
|
|
__refreshActive: false,
|
|
|
|
/** Callback to execute on activation. This is for signalling the user about a refresh is about to happen when he release */
|
|
__refreshActivate: null,
|
|
|
|
/** Callback to execute on deactivation. This is for signalling the user about the refresh being cancelled */
|
|
__refreshDeactivate: null,
|
|
|
|
/** Callback to execute to start the actual refresh. Call {@link #refreshFinish} when done */
|
|
__refreshStart: null,
|
|
|
|
/** Zoom level */
|
|
__zoomLevel: 1,
|
|
|
|
/** Scroll position on x-axis */
|
|
__scrollLeft: 0,
|
|
|
|
/** Scroll position on y-axis */
|
|
__scrollTop: 0,
|
|
|
|
/** Maximum allowed scroll position on x-axis */
|
|
__maxScrollLeft: 0,
|
|
|
|
/** Maximum allowed scroll position on y-axis */
|
|
__maxScrollTop: 0,
|
|
|
|
/* Scheduled left position (final position when animating) */
|
|
__scheduledLeft: 0,
|
|
|
|
/* Scheduled top position (final position when animating) */
|
|
__scheduledTop: 0,
|
|
|
|
/* Scheduled zoom level (final scale when animating) */
|
|
__scheduledZoom: 0,
|
|
|
|
|
|
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
INTERNAL FIELDS :: LAST POSITIONS
|
|
---------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** Left position of finger at start */
|
|
__lastTouchLeft: null,
|
|
|
|
/** Top position of finger at start */
|
|
__lastTouchTop: null,
|
|
|
|
/** Timestamp of last move of finger. Used to limit tracking range for deceleration speed. */
|
|
__lastTouchMove: null,
|
|
|
|
/** List of positions, uses three indexes for each state: left, top, timestamp */
|
|
__positions: null,
|
|
|
|
|
|
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
INTERNAL FIELDS :: DECELERATION SUPPORT
|
|
---------------------------------------------------------------------------
|
|
*/
|
|
|
|
/** Minimum left scroll position during deceleration */
|
|
__minDecelerationScrollLeft: null,
|
|
|
|
/** Minimum top scroll position during deceleration */
|
|
__minDecelerationScrollTop: null,
|
|
|
|
/** Maximum left scroll position during deceleration */
|
|
__maxDecelerationScrollLeft: null,
|
|
|
|
/** Maximum top scroll position during deceleration */
|
|
__maxDecelerationScrollTop: null,
|
|
|
|
/** Current factor to modify horizontal scroll position with on every step */
|
|
__decelerationVelocityX: null,
|
|
|
|
/** Current factor to modify vertical scroll position with on every step */
|
|
__decelerationVelocityY: null,
|
|
|
|
|
|
/** the browser-specific property to use for transforms */
|
|
__transformProperty: null,
|
|
__perspectiveProperty: null,
|
|
|
|
/** scrollbar indicators */
|
|
__indicatorX: null,
|
|
__indicatorY: null,
|
|
|
|
/** Timeout for scrollbar fading */
|
|
__scrollbarFadeTimeout: null,
|
|
|
|
/** whether we've tried to wait for size already */
|
|
__didWaitForSize: null,
|
|
__sizerTimeout: null,
|
|
|
|
__initEventHandlers: function() {
|
|
var self = this;
|
|
|
|
// Event Handler
|
|
var container = this.__container;
|
|
|
|
if ('ontouchstart' in window) {
|
|
|
|
container.addEventListener("touchstart", function(e) {
|
|
// Don't react if initial down happens on a form element
|
|
if (e.target.tagName.match(/input|textarea|select/i)) {
|
|
return;
|
|
}
|
|
|
|
self.doTouchStart(e.touches, e.timeStamp);
|
|
e.preventDefault();
|
|
}, false);
|
|
|
|
document.addEventListener("touchmove", function(e) {
|
|
if(e.defaultPrevented) {
|
|
return;
|
|
}
|
|
self.doTouchMove(e.touches, e.timeStamp);
|
|
}, false);
|
|
|
|
document.addEventListener("touchend", function(e) {
|
|
self.doTouchEnd(e.timeStamp);
|
|
}, false);
|
|
|
|
} else {
|
|
|
|
var mousedown = false;
|
|
|
|
container.addEventListener("mousedown", function(e) {
|
|
// Don't react if initial down happens on a form element
|
|
if (e.target.tagName.match(/input|textarea|select/i)) {
|
|
return;
|
|
}
|
|
|
|
self.doTouchStart([{
|
|
pageX: e.pageX,
|
|
pageY: e.pageY
|
|
}], e.timeStamp);
|
|
|
|
mousedown = true;
|
|
}, false);
|
|
|
|
document.addEventListener("mousemove", function(e) {
|
|
if (!mousedown || e.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
self.doTouchMove([{
|
|
pageX: e.pageX,
|
|
pageY: e.pageY
|
|
}], e.timeStamp);
|
|
|
|
mousedown = true;
|
|
}, false);
|
|
|
|
document.addEventListener("mouseup", function(e) {
|
|
if (!mousedown) {
|
|
return;
|
|
}
|
|
|
|
self.doTouchEnd(e.timeStamp);
|
|
|
|
mousedown = false;
|
|
}, false);
|
|
|
|
}
|
|
},
|
|
|
|
/** Create a scroll bar div with the given direction **/
|
|
__createScrollbar: function(direction) {
|
|
var bar = document.createElement('div'),
|
|
indicator = document.createElement('div');
|
|
|
|
indicator.className = 'scroll-bar-indicator';
|
|
|
|
if(direction == 'h') {
|
|
bar.className = 'scroll-bar scroll-bar-h';
|
|
} else {
|
|
bar.className = 'scroll-bar scroll-bar-v';
|
|
}
|
|
|
|
bar.appendChild(indicator);
|
|
return bar;
|
|
},
|
|
|
|
__createScrollbars: function() {
|
|
var indicatorX, indicatorY;
|
|
|
|
if(this.options.scrollingX) {
|
|
indicatorX = {
|
|
el: this.__createScrollbar('h'),
|
|
sizeRatio: 1
|
|
};
|
|
indicatorX.indicator = indicatorX.el.children[0];
|
|
|
|
if(this.options.scrollbarX) {
|
|
this.__container.appendChild(indicatorX.el);
|
|
}
|
|
this.__indicatorX = indicatorX;
|
|
}
|
|
|
|
if(this.options.scrollingY) {
|
|
indicatorY = {
|
|
el: this.__createScrollbar('v'),
|
|
sizeRatio: 1
|
|
};
|
|
indicatorY.indicator = indicatorY.el.children[0];
|
|
|
|
if(this.options.scrollbarY) {
|
|
this.__container.appendChild(indicatorY.el);
|
|
}
|
|
this.__indicatorY = indicatorY;
|
|
}
|
|
},
|
|
|
|
__resizeScrollbars: function() {
|
|
var self = this;
|
|
|
|
// Bring the scrollbars in to show the content change
|
|
self.__fadeScrollbars('in');
|
|
|
|
// Update horiz bar
|
|
if(self.__indicatorX) {
|
|
var width = Math.max(Math.round(self.__clientWidth * self.__clientWidth / (self.__contentWidth)), 20);
|
|
if(width > self.__contentWidth) {
|
|
width = 0;
|
|
}
|
|
self.__indicatorX.size = width;
|
|
self.__indicatorX.minScale = this.options.minScrollbarSizeX / width;
|
|
self.__indicatorX.indicator.style.width = width + 'px';
|
|
self.__indicatorX.maxPos = self.__clientWidth - width;
|
|
self.__indicatorX.sizeRatio = self.__maxScrollLeft ? self.__indicatorX.maxPos / self.__maxScrollLeft : 1;
|
|
}
|
|
|
|
// Update vert bar
|
|
if(self.__indicatorY) {
|
|
var height = Math.max(Math.round(self.__clientHeight * self.__clientHeight / (self.__contentHeight)), 20);
|
|
if(height > self.__contentHeight) {
|
|
height = 0;
|
|
}
|
|
self.__indicatorY.size = height;
|
|
self.__indicatorY.minScale = this.options.minScrollbarSizeY / height;
|
|
self.__indicatorY.maxPos = self.__clientHeight - height;
|
|
self.__indicatorY.indicator.style.height = height + 'px';
|
|
self.__indicatorY.sizeRatio = self.__maxScrollTop ? self.__indicatorY.maxPos / self.__maxScrollTop : 1;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Move and scale the scrollbars as the page scrolls.
|
|
*/
|
|
__repositionScrollbars: function() {
|
|
var self = this, width, heightScale,
|
|
widthDiff, heightDiff,
|
|
x, y,
|
|
xstop = 0, ystop = 0;
|
|
|
|
if(self.__indicatorX) {
|
|
// Handle the X scrollbar
|
|
|
|
// Don't go all the way to the right if we have a vertical scrollbar as well
|
|
if(self.__indicatorY) xstop = 10;
|
|
|
|
x = Math.round(self.__indicatorX.sizeRatio * self.__scrollLeft) || 0,
|
|
|
|
// The the difference between the last content X position, and our overscrolled one
|
|
widthDiff = self.__scrollLeft - (self.__maxScrollLeft - xstop);
|
|
|
|
if(self.__scrollLeft < 0) {
|
|
|
|
widthScale = Math.max(self.__indicatorX.minScale,
|
|
(self.__indicatorX.size - Math.abs(self.__scrollLeft)) / self.__indicatorX.size);
|
|
|
|
// Stay at left
|
|
x = 0;
|
|
|
|
// Make sure scale is transformed from the left/center origin point
|
|
self.__indicatorX.indicator.style[self.__transformOriginProperty] = 'left center';
|
|
} else if(widthDiff > 0) {
|
|
|
|
widthScale = Math.max(self.__indicatorX.minScale,
|
|
(self.__indicatorX.size - widthDiff) / self.__indicatorX.size);
|
|
|
|
// Stay at the furthest x for the scrollable viewport
|
|
x = self.__indicatorX.maxPos - xstop;
|
|
|
|
// Make sure scale is transformed from the right/center origin point
|
|
self.__indicatorX.indicator.style[self.__transformOriginProperty] = 'right center';
|
|
|
|
} else {
|
|
|
|
// Normal motion
|
|
x = Math.min(self.__maxScrollLeft, Math.max(0, x));
|
|
widthScale = 1;
|
|
|
|
}
|
|
|
|
self.__indicatorX.indicator.style[self.__transformProperty] = 'translate3d(' + x + 'px, 0, 0) scaleX(' + widthScale + ')';
|
|
}
|
|
|
|
if(self.__indicatorY) {
|
|
|
|
y = Math.round(self.__indicatorY.sizeRatio * self.__scrollTop) || 0;
|
|
|
|
// Don't go all the way to the right if we have a vertical scrollbar as well
|
|
if(self.__indicatorX) ystop = 10;
|
|
|
|
heightDiff = self.__scrollTop - (self.__maxScrollTop - ystop);
|
|
|
|
if(self.__scrollTop < 0) {
|
|
|
|
heightScale = Math.max(self.__indicatorY.minScale, (self.__indicatorY.size - Math.abs(self.__scrollTop)) / self.__indicatorY.size);
|
|
|
|
// Stay at top
|
|
y = 0;
|
|
|
|
// Make sure scale is transformed from the center/top origin point
|
|
self.__indicatorY.indicator.style[self.__transformOriginProperty] = 'center top';
|
|
|
|
} else if(heightDiff > 0) {
|
|
|
|
heightScale = Math.max(self.__indicatorY.minScale, (self.__indicatorY.size - heightDiff) / self.__indicatorY.size);
|
|
|
|
// Stay at bottom of scrollable viewport
|
|
y = self.__indicatorY.maxPos - ystop;
|
|
|
|
// Make sure scale is transformed from the center/bottom origin point
|
|
self.__indicatorY.indicator.style[self.__transformOriginProperty] = 'center bottom';
|
|
|
|
} else {
|
|
|
|
// Normal motion
|
|
y = Math.min(self.__maxScrollTop, Math.max(0, y));
|
|
heightScale = 1;
|
|
|
|
}
|
|
|
|
self.__indicatorY.indicator.style[self.__transformProperty] = 'translate3d(0,' + y + 'px, 0) scaleY(' + heightScale + ')';
|
|
}
|
|
},
|
|
|
|
__fadeScrollbars: function(direction, delay) {
|
|
var self = this;
|
|
|
|
if(!this.options.scrollbarsFade) {
|
|
return;
|
|
}
|
|
|
|
var className = 'scroll-bar-fade-out';
|
|
|
|
if(self.options.scrollbarsFade === true) {
|
|
clearTimeout(self.__scrollbarFadeTimeout);
|
|
|
|
if(direction == 'in') {
|
|
if(self.__indicatorX) { self.__indicatorX.indicator.classList.remove(className); }
|
|
if(self.__indicatorY) { self.__indicatorY.indicator.classList.remove(className); }
|
|
} else {
|
|
self.__scrollbarFadeTimeout = setTimeout(function() {
|
|
if(self.__indicatorX) { self.__indicatorX.indicator.classList.add(className); }
|
|
if(self.__indicatorY) { self.__indicatorY.indicator.classList.add(className); }
|
|
}, delay || self.options.scrollbarFadeDelay);
|
|
}
|
|
}
|
|
},
|
|
|
|
__scrollingComplete: function() {
|
|
var self = this;
|
|
self.options.scrollingComplete();
|
|
|
|
self.__fadeScrollbars('out');
|
|
},
|
|
|
|
resize: function() {
|
|
// Update Scroller dimensions for changed content
|
|
// Add padding to bottom of content
|
|
this.setDimensions(
|
|
this.__container.clientWidth,
|
|
this.__container.clientHeight,
|
|
Math.max(this.__content.scrollWidth, this.__content.offsetWidth),
|
|
Math.max(this.__content.scrollHeight, this.__content.offsetHeight+20));
|
|
},
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
PUBLIC API
|
|
---------------------------------------------------------------------------
|
|
*/
|
|
|
|
getRenderFn: function() {
|
|
var self = this;
|
|
|
|
var content = this.__content;
|
|
|
|
var docStyle = document.documentElement.style;
|
|
|
|
var engine;
|
|
if ('MozAppearance' in docStyle) {
|
|
engine = 'gecko';
|
|
} else if ('WebkitAppearance' in docStyle) {
|
|
engine = 'webkit';
|
|
} else if (typeof navigator.cpuClass === 'string') {
|
|
engine = 'trident';
|
|
}
|
|
|
|
var vendorPrefix = {
|
|
trident: 'ms',
|
|
gecko: 'Moz',
|
|
webkit: 'Webkit',
|
|
presto: 'O'
|
|
}[engine];
|
|
|
|
var helperElem = document.createElement("div");
|
|
var undef;
|
|
|
|
var perspectiveProperty = vendorPrefix + "Perspective";
|
|
var transformProperty = vendorPrefix + "Transform";
|
|
var transformOriginProperty = vendorPrefix + 'TransformOrigin';
|
|
|
|
self.__perspectiveProperty = transformProperty;
|
|
self.__transformProperty = transformProperty;
|
|
self.__transformOriginProperty = transformOriginProperty;
|
|
|
|
if (helperElem.style[perspectiveProperty] !== undef) {
|
|
|
|
return function(left, top, zoom) {
|
|
content.style[transformProperty] = 'translate3d(' + (-left) + 'px,' + (-top) + 'px,0)';
|
|
self.__repositionScrollbars();
|
|
self.triggerScrollEvent();
|
|
};
|
|
|
|
} else if (helperElem.style[transformProperty] !== undef) {
|
|
|
|
return function(left, top, zoom) {
|
|
content.style[transformProperty] = 'translate(' + (-left) + 'px,' + (-top) + 'px)';
|
|
self.__repositionScrollbars();
|
|
self.triggerScrollEvent();
|
|
};
|
|
|
|
} else {
|
|
|
|
return function(left, top, zoom) {
|
|
content.style.marginLeft = left ? (-left/zoom) + 'px' : '';
|
|
content.style.marginTop = top ? (-top/zoom) + 'px' : '';
|
|
content.style.zoom = zoom || '';
|
|
self.__repositionScrollbars();
|
|
self.triggerScrollEvent();
|
|
};
|
|
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* 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} Inner width of outer element
|
|
* @param clientHeight {Integer} Inner height of outer element
|
|
* @param contentWidth {Integer} Outer width of inner element
|
|
* @param contentHeight {Integer} Outer height of inner element
|
|
*/
|
|
setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight) {
|
|
|
|
var self = this;
|
|
|
|
// Only update values which are defined
|
|
if (clientWidth === +clientWidth) {
|
|
self.__clientWidth = clientWidth;
|
|
}
|
|
|
|
if (clientHeight === +clientHeight) {
|
|
self.__clientHeight = clientHeight;
|
|
}
|
|
|
|
if (contentWidth === +contentWidth) {
|
|
self.__contentWidth = contentWidth;
|
|
}
|
|
|
|
if (contentHeight === +contentHeight) {
|
|
self.__contentHeight = contentHeight;
|
|
}
|
|
|
|
// Refresh maximums
|
|
self.__computeScrollMax();
|
|
self.__resizeScrollbars();
|
|
|
|
// Refresh scroll position
|
|
self.scrollTo(self.__scrollLeft, self.__scrollTop, true);
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
* Sets the client coordinates in relation to the document.
|
|
*
|
|
* @param left {Integer} Left position of outer element
|
|
* @param top {Integer} 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;
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
* Starts pull-to-refresh manually.
|
|
*/
|
|
triggerPullToRefresh: function() {
|
|
// 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
|
|
this.__publish(this.__scrollLeft, -this.__refreshHeight, this.__zoomLevel, true);
|
|
|
|
if (this.__refreshStart) {
|
|
this.__refreshStart();
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* 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} Whether to use animation
|
|
* @param originLeft {Number} Zoom in at given left coordinate
|
|
* @param originTop {Number} 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} Whether to use animation
|
|
* @param originLeft {Number} Zoom in at given left coordinate
|
|
* @param originTop {Number} 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} Horizontal scroll position, keeps current if value is <code>null</code>
|
|
* @param top {Number} Vertical scroll position, keeps current if value is <code>null</code>
|
|
* @param animate {Boolean} Whether the scrolling should happen using an animation
|
|
* @param zoom {Number} 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} Scroll x-axis by given offset
|
|
* @param top {Number} Scroll x-axis by given offset
|
|
* @param animate {Boolean} 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) {
|
|
this.hintResize();
|
|
|
|
// 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;
|
|
|
|
self.__fadeScrollbars('in');
|
|
|
|
// Reset interruptedAnimation flag
|
|
self.__interruptedAnimation = true;
|
|
|
|
// Stop deceleration
|
|
if (self.__isDecelerating) {
|
|
core.effect.Animate.stop(self.__isDecelerating);
|
|
self.__isDecelerating = false;
|
|
self.__interruptedAnimation = true;
|
|
}
|
|
|
|
// Stop animation
|
|
if (self.__isAnimating) {
|
|
core.effect.Animate.stop(self.__isAnimating);
|
|
self.__isAnimating = false;
|
|
self.__interruptedAnimation = true;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Reset deceleration complete flag
|
|
self.__didDecelerationComplete = false;
|
|
|
|
// 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 * this.options.speedMultiplier;
|
|
var maxScrollLeft = self.__maxScrollLeft;
|
|
|
|
if (scrollLeft > maxScrollLeft || scrollLeft < 0) {
|
|
|
|
// Slow down on the edges
|
|
if (self.options.bouncing) {
|
|
|
|
scrollLeft += (moveX / 2 * this.options.speedMultiplier);
|
|
|
|
} else if (scrollLeft > maxScrollLeft) {
|
|
|
|
scrollLeft = maxScrollLeft;
|
|
|
|
} else {
|
|
|
|
scrollLeft = 0;
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute new vertical scroll position
|
|
if (self.__enableScrollY) {
|
|
|
|
scrollTop -= moveY * this.options.speedMultiplier;
|
|
var maxScrollTop = self.__maxScrollTop;
|
|
|
|
if (scrollTop > maxScrollTop || scrollTop < 0) {
|
|
|
|
// Slow down on the edges
|
|
if (self.options.bouncing || (self.__refreshHeight && scrollTop < 0)) {
|
|
|
|
scrollTop += (moveY / 2 * this.options.speedMultiplier);
|
|
|
|
// 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);
|
|
if (self.__isDragging) {
|
|
self.__interruptedAnimation = false;
|
|
}
|
|
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
} else {
|
|
self.__scrollingComplete();
|
|
}
|
|
} else if ((timeStamp - self.__lastTouchMove) > 100) {
|
|
self.__scrollingComplete();
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
|
|
if (self.__interruptedAnimation || self.__isDragging) {
|
|
self.__scrollingComplete();
|
|
}
|
|
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} 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.__didDecelerationComplete || wasFinished) {
|
|
self.__scrollingComplete();
|
|
}
|
|
|
|
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);
|
|
|
|
if(!self.__didWaitForSize && self.__maxScrollLeft == 0 && self.__maxScrollTop == 0) {
|
|
self.__didWaitForSize = true;
|
|
self.__waitForSize();
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* If the scroll view isn't sized correctly on start, wait until we have at least some size
|
|
*/
|
|
__waitForSize: function() {
|
|
|
|
var self = this;
|
|
|
|
clearTimeout(self.__sizerTimeout);
|
|
|
|
var sizer = function() {
|
|
self.resize();
|
|
|
|
if((self.options.scrollingX && self.__maxScrollLeft == 0) || (self.options.scrollingY && self.__maxScrollTop == 0)) {
|
|
//self.__sizerTimeout = setTimeout(sizer, 1000);
|
|
}
|
|
};
|
|
|
|
sizer();
|
|
self.__sizerTimeout = setTimeout(sizer, 1000);
|
|
},
|
|
|
|
/*
|
|
---------------------------------------------------------------------------
|
|
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() {
|
|
var shouldContinue = Math.abs(self.__decelerationVelocityX) >= minVelocityToKeepDecelerating || Math.abs(self.__decelerationVelocityY) >= minVelocityToKeepDecelerating;
|
|
if (!shouldContinue) {
|
|
self.__didDecelerationComplete = true;
|
|
}
|
|
return shouldContinue;
|
|
};
|
|
|
|
var completed = function(renderedFramesPerSecond, animationId, wasFinished) {
|
|
self.__isDecelerating = false;
|
|
if (self.__didDecelerationComplete) {
|
|
self.__scrollingComplete();
|
|
}
|
|
|
|
// Animate to grid when snapping is active, otherwise just fix out-of-boundary positions
|
|
if(self.options.paging) {
|
|
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} 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 = self.options.penetrationDeceleration;
|
|
var penetrationAcceleration = self.options.penetrationAcceleration;
|
|
|
|
// 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 <= self.__minDecelerationScrollLeft) {
|
|
self.__decelerationVelocityX += scrollOutsideX * penetrationDeceleration;
|
|
} else {
|
|
self.__decelerationVelocityX = scrollOutsideX * penetrationAcceleration;
|
|
}
|
|
}
|
|
|
|
if (scrollOutsideY !== 0) {
|
|
if (scrollOutsideY * self.__decelerationVelocityY <= self.__minDecelerationScrollTop) {
|
|
self.__decelerationVelocityY += scrollOutsideY * penetrationDeceleration;
|
|
} else {
|
|
self.__decelerationVelocityY = scrollOutsideY * penetrationAcceleration;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
})(ionic);
|