From 3267e80e646363fb93112012d6e115be60eb3267 Mon Sep 17 00:00:00 2001 From: Max Lynch Date: Wed, 27 Nov 2013 14:42:04 -0600 Subject: [PATCH 01/24] Zynga Scroller in there --- Gruntfile.js | 1 + dist/js/ionic.js | 1572 +++++++++++++++++++++++++++++++++ js/ext/angular/test/zyng.html | 141 +++ js/views/scrollZyng.js | 1571 ++++++++++++++++++++++++++++++++ 4 files changed, 3285 insertions(+) create mode 100644 js/ext/angular/test/zyng.html create mode 100644 js/views/scrollZyng.js diff --git a/Gruntfile.js b/Gruntfile.js index a742dc253f..b3644b5bca 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -19,6 +19,7 @@ module.exports = function(grunt) { 'js/views/view.js', 'js/views/scrollView.js', + 'js/views/scrollZyng.js', 'js/views/actionSheetView.js', 'js/views/checkboxView.js', diff --git a/dist/js/ionic.js b/dist/js/ionic.js index bc2a115336..699cfcd4a8 100644 --- a/dist/js/ionic.js +++ b/dist/js/ionic.js @@ -2830,6 +2830,1578 @@ window.ionic = { DECEL_RATE_SLOW: 0.996, }); +})(ionic); +; +/* + * 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(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); + }; + + + + /** + * A pure logic 'component' for 'virtual' scrolling/zooming. + */ + ionic.views.Scroller = ionic.views.View.inherit({ + initialize: 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, + + /** 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 + + }; + + for (var key in options) { + this.options[key] = options[key]; + } + + }, + + + + /* + --------------------------------------------------------------------------- + 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 deceleration animation went to completion. */ + __didDecelerationComplete: 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, + + /** {Number} Scroll position on x-axis */ + __scrollLeft: 0, + + /** {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 === +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(); + + // 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; + + }, + + + /** + * 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 ? 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; + + // 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) { + + 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.options.scrollingComplete(); + } + } else if ((timeStamp - self.__lastTouchMove) > 100) { + self.options.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.options.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?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.__didDecelerationComplete || wasFinished) { + self.options.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); + + }, + + + + /* + --------------------------------------------------------------------------- + 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.options.scrollingComplete(); + } + + // 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 = 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 <= 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; + } + } + } + } +}); + })(ionic); ; (function(ionic) { diff --git a/js/ext/angular/test/zyng.html b/js/ext/angular/test/zyng.html new file mode 100644 index 0000000000..b25b7cb624 --- /dev/null +++ b/js/ext/angular/test/zyng.html @@ -0,0 +1,141 @@ + + + + Content + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + + diff --git a/js/views/scrollZyng.js b/js/views/scrollZyng.js new file mode 100644 index 0000000000..885e50d3cc --- /dev/null +++ b/js/views/scrollZyng.js @@ -0,0 +1,1571 @@ +/* + * 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(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); + }; + + + + /** + * A pure logic 'component' for 'virtual' scrolling/zooming. + */ + ionic.views.Scroller = ionic.views.View.inherit({ + initialize: 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, + + /** 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 + + }; + + for (var key in options) { + this.options[key] = options[key]; + } + + }, + + + + /* + --------------------------------------------------------------------------- + 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 deceleration animation went to completion. */ + __didDecelerationComplete: 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, + + /** {Number} Scroll position on x-axis */ + __scrollLeft: 0, + + /** {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 === +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(); + + // 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; + + }, + + + /** + * 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 ? 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; + + // 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) { + + 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.options.scrollingComplete(); + } + } else if ((timeStamp - self.__lastTouchMove) > 100) { + self.options.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.options.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?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.__didDecelerationComplete || wasFinished) { + self.options.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); + + }, + + + + /* + --------------------------------------------------------------------------- + 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.options.scrollingComplete(); + } + + // 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 = 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 <= 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; + } + } + } + } +}); + +})(ionic); From c9b384c8a85ded5da956cc2cc5603a902981661b Mon Sep 17 00:00:00 2001 From: Max Lynch Date: Wed, 27 Nov 2013 14:54:48 -0600 Subject: [PATCH 02/24] Slightly easier scroll API --- dist/js/ionic.js | 58 ++++++++++++++- js/ext/angular/test/zyng.html | 2 +- js/views/scrollZyng.js | 58 ++++++++++++++- test/scroll_z.html | 134 ++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 test/scroll_z.html diff --git a/dist/js/ionic.js b/dist/js/ionic.js index 699cfcd4a8..e9310fa613 100644 --- a/dist/js/ionic.js +++ b/dist/js/ionic.js @@ -3112,14 +3112,15 @@ var Scroller; }; - /** * A pure logic 'component' for 'virtual' scrolling/zooming. */ ionic.views.Scroller = ionic.views.View.inherit({ - initialize: function(callback, options) { + initialize: function(options) { - this.__callback = callback; + this.__content = options.content; + + this.__callback = this.getRenderFn(); this.options = { @@ -3344,6 +3345,57 @@ var Scroller; --------------------------------------------------------------------------- */ + getRenderFn: function() { + 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"; + + if (helperElem.style[perspectiveProperty] !== undef) { + + return function(left, top, zoom) { + content.style[transformProperty] = 'translate3d(' + (-left) + 'px,' + (-top) + 'px,0) scale(' + zoom + ')'; + }; + + } else if (helperElem.style[transformProperty] !== undef) { + + return function(left, top, zoom) { + content.style[transformProperty] = 'translate(' + (-left) + 'px,' + (-top) + 'px) scale(' + zoom + ')'; + }; + + } else { + + return function(left, top, zoom) { + content.style.marginLeft = left ? (-left/zoom) + 'px' : ''; + content.style.marginTop = top ? (-top/zoom) + 'px' : ''; + content.style.zoom = zoom || ''; + }; + + } + }, + + /** * 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. diff --git a/js/ext/angular/test/zyng.html b/js/ext/angular/test/zyng.html index b25b7cb624..567d736b6e 100644 --- a/js/ext/angular/test/zyng.html +++ b/js/ext/angular/test/zyng.html @@ -65,7 +65,7 @@ var container = document.getElementById('c'); // Initialize Scroller - var scroller = new ionic.views.Scroller(render, { + var scroller = new ionic.views.Scroller({ scrollingX: false }); diff --git a/js/views/scrollZyng.js b/js/views/scrollZyng.js index 885e50d3cc..c9baa8e2d6 100644 --- a/js/views/scrollZyng.js +++ b/js/views/scrollZyng.js @@ -278,14 +278,15 @@ var Scroller; }; - /** * A pure logic 'component' for 'virtual' scrolling/zooming. */ ionic.views.Scroller = ionic.views.View.inherit({ - initialize: function(callback, options) { + initialize: function(options) { - this.__callback = callback; + this.__content = options.content; + + this.__callback = this.getRenderFn(); this.options = { @@ -510,6 +511,57 @@ var Scroller; --------------------------------------------------------------------------- */ + getRenderFn: function() { + 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"; + + if (helperElem.style[perspectiveProperty] !== undef) { + + return function(left, top, zoom) { + content.style[transformProperty] = 'translate3d(' + (-left) + 'px,' + (-top) + 'px,0) scale(' + zoom + ')'; + }; + + } else if (helperElem.style[transformProperty] !== undef) { + + return function(left, top, zoom) { + content.style[transformProperty] = 'translate(' + (-left) + 'px,' + (-top) + 'px) scale(' + zoom + ')'; + }; + + } else { + + return function(left, top, zoom) { + content.style.marginLeft = left ? (-left/zoom) + 'px' : ''; + content.style.marginTop = top ? (-top/zoom) + 'px' : ''; + content.style.zoom = zoom || ''; + }; + + } + }, + + /** * 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. diff --git a/test/scroll_z.html b/test/scroll_z.html new file mode 100644 index 0000000000..ecf5d88672 --- /dev/null +++ b/test/scroll_z.html @@ -0,0 +1,134 @@ + + + + Content + + + + + + + + + +
+
+
+
+
+ + + + + From 4e42d7c996be8d1819920f1aaef4702befbfe113 Mon Sep 17 00:00:00 2001 From: Max Lynch Date: Wed, 27 Nov 2013 15:10:06 -0600 Subject: [PATCH 03/24] Event easier scroll API --- dist/js/ionic.js | 80 ++++++++++++++++++++++++++++++++++++++++-- js/views/scrollZyng.js | 80 ++++++++++++++++++++++++++++++++++++++++-- test/scroll_z.html | 75 ++------------------------------------- 3 files changed, 158 insertions(+), 77 deletions(-) diff --git a/dist/js/ionic.js b/dist/js/ionic.js index e9310fa613..f472096583 100644 --- a/dist/js/ionic.js +++ b/dist/js/ionic.js @@ -3118,9 +3118,9 @@ var Scroller; ionic.views.Scroller = ionic.views.View.inherit({ initialize: function(options) { - this.__content = options.content; + this.__container = options.content; + this.__content = options.content.firstElementChild; - this.__callback = this.getRenderFn(); this.options = { @@ -3177,6 +3177,11 @@ var Scroller; this.options[key] = options[key]; } + // Get the render update function, initialize event handlers, + // and calculate the size of the scroll container + this.__callback = this.getRenderFn(); + this.__initEventHandlers(); + this.resize(); }, @@ -3338,7 +3343,78 @@ var Scroller; __decelerationVelocityY: null, + __initEventHandlers: function() { + // Event Handler + + 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; + } + + scroller.doTouchStart(e.touches, e.timeStamp); + e.preventDefault(); + }, false); + document.addEventListener("touchmove", function(e) { + scroller.doTouchMove(e.touches, e.timeStamp); + }, false); + + document.addEventListener("touchend", function(e) { + scroller.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; + } + + scroller.doTouchStart([{ + pageX: e.pageX, + pageY: e.pageY + }], e.timeStamp); + + mousedown = true; + }, false); + + document.addEventListener("mousemove", function(e) { + if (!mousedown) { + return; + } + + scroller.doTouchMove([{ + pageX: e.pageX, + pageY: e.pageY + }], e.timeStamp); + + mousedown = true; + }, false); + + document.addEventListener("mouseup", function(e) { + if (!mousedown) { + return; + } + + scroller.doTouchEnd(e.timeStamp); + + mousedown = false; + }, false); + + } + }, + + + resize: function() { + // Update Scroller dimensions for changed content + this.setDimensions(this.__container.clientWidth, this.__container.clientHeight, this.__content.offsetWidth, this.__content.offsetHeight-50); + }, /* --------------------------------------------------------------------------- PUBLIC API diff --git a/js/views/scrollZyng.js b/js/views/scrollZyng.js index c9baa8e2d6..6054690096 100644 --- a/js/views/scrollZyng.js +++ b/js/views/scrollZyng.js @@ -284,9 +284,9 @@ var Scroller; ionic.views.Scroller = ionic.views.View.inherit({ initialize: function(options) { - this.__content = options.content; + this.__container = options.content; + this.__content = options.content.firstElementChild; - this.__callback = this.getRenderFn(); this.options = { @@ -343,6 +343,11 @@ var Scroller; this.options[key] = options[key]; } + // Get the render update function, initialize event handlers, + // and calculate the size of the scroll container + this.__callback = this.getRenderFn(); + this.__initEventHandlers(); + this.resize(); }, @@ -504,7 +509,78 @@ var Scroller; __decelerationVelocityY: null, + __initEventHandlers: function() { + // Event Handler + + 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; + } + + scroller.doTouchStart(e.touches, e.timeStamp); + e.preventDefault(); + }, false); + document.addEventListener("touchmove", function(e) { + scroller.doTouchMove(e.touches, e.timeStamp); + }, false); + + document.addEventListener("touchend", function(e) { + scroller.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; + } + + scroller.doTouchStart([{ + pageX: e.pageX, + pageY: e.pageY + }], e.timeStamp); + + mousedown = true; + }, false); + + document.addEventListener("mousemove", function(e) { + if (!mousedown) { + return; + } + + scroller.doTouchMove([{ + pageX: e.pageX, + pageY: e.pageY + }], e.timeStamp); + + mousedown = true; + }, false); + + document.addEventListener("mouseup", function(e) { + if (!mousedown) { + return; + } + + scroller.doTouchEnd(e.timeStamp); + + mousedown = false; + }, false); + + } + }, + + + resize: function() { + // Update Scroller dimensions for changed content + this.setDimensions(this.__container.clientWidth, this.__container.clientHeight, this.__content.offsetWidth, this.__content.offsetHeight-50); + }, /* --------------------------------------------------------------------------- PUBLIC API diff --git a/test/scroll_z.html b/test/scroll_z.html index ecf5d88672..8b169319be 100644 --- a/test/scroll_z.html +++ b/test/scroll_z.html @@ -9,7 +9,7 @@ - + @@ -67,7 +67,7 @@ has-footer="true" > -