'use strict'; /* global Service */ (function(exports) { /** * UtilityTrayMotion manages the scrolling and snapping behavior of the * UtilityTray. * * UtilityTrayMotion emits the following custom events from its root element: * * - "tray-motion-state", emitted when the element changes state. * - "tray-motion-footer-position", emitted when the tray moves in such a way * that you might want to show or hide the fixed-position footer. * * The state of the utility tray motion (UtilityTrayMotion.state) will always * be one of "opening", "closing", "open", or "closed". * * Rather than manually intercepting touch events to provide momentum, we use * native scrolling to improve performance. A scrollable root div contains two * sections, one with the utility tray contents, and one transparent unclickable * area. When the tray is closed, only the invisible (dead area) fits on the * screen; when the tray is open, only the visible area fits onscreen. Hence * the screen is always overlaid with the utility tray, but the dead area * has "pointer-events: none". See the following layout diagram: * * * +=====================================+ * | .tray #utility-tray-motion | * | (scrollable) | * | +-------------------------------------+ * | | .tray-content #utility-tray | * | | (opaque, grabbable) | * | | | * | |=====================================| <-. * | | #tray-invisible-gripper | | * | | (transparent, grabbable) | | * | |-------------------------------------| | Visible Screen * | | .tray-dead-area | | (in this diagram, the * | | (transparent, no touch events) | | tray is fully closed.) * | | | | * | +-------------------------------------+ <-' * +=====================================+ * * * Note that we do _not_ use CSS scroll snapping here. As of July 2015, CSS * scroll snapping's velocity calculations do not include the element's current * scroll velocity when calculating fling velocity; this means that upon lifting * your finger, you would be unable to scroll the utility tray quickly. Instead, * we listen to scroll events, and based upon the current tray velocity and * position, we decide when to fling the tray ourselves. * * With this implementation, if you fling the tray really fast, we recognize * that the tray will make it to the final scroll position without assistance; * this triggers the platform bounce effects. If you fling the tray too slowly, * we'll recognize that the tray will get stuck in the middle of the screen, * so we push it the rest of the way with `scrollTo()`. * *------------------------------------------------------------------------------ * TRACKING SCROLL POSITION * * Because we now support asynchronous scrolling (APZ), we can never predict * the element's exact scroll position at any given time. We can monitor touch * and scroll events, but `scrollTop` is always an out-of-date snapshot. * We cannot cancel scroll events synchronously, nor can we rely on touch events * to calculate the element's curent scroll offset, because the platform applies * momentum itself. * * Fortunately, we can compensate for this lack of control by tracking the * element's scrollTop whenever a touch or scroll event occurs. In this way, * we can update the tray state ('open', 'closed', 'opening', 'closing') in * response to changes in scroll position. When we need to move the tray * manually, we use `scrollTo({ behavior: 'smooth'})`. * * @param {Element} el */ function UtilityTrayMotion(el) { this.el = el; ['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach((type) => { this.el.addEventListener(type, this._ontouch.bind(this)); }); this.el.addEventListener('scroll', this._onscroll.bind(this)); // Since scroll events are unpredictable, we can't reliably calculate scroll // velocity without averaging over a few samples. This value is primarily // used to calculate whether or not the tray will close or open fully without // further assistance; it is cleared when the tray changes direction to // ensure quick velocity calculations in response to direction changes. const VELOCITY_WINDOW_MS = 200; this._velocityAverager = new TimeWindowedAverager(VELOCITY_WINDOW_MS); // It can take a moment for scroll events to catch up with touch gestures. // Don't force the tray to open or close until some time has passed. this._lastTouchTime = 0; this.isTouching = false; this.currentTouch = null; // used by UtilityTray for event forwarding // Begin closed. Note that scrollTop may be the inverse of what you'd expect; // a scrollTop of zero indicates the tray is open; a scrollTop of scrollTopMax // indicates the tray is closed. this._setState('closing'); this.position = this.el.scrollTop = this.el.scrollTopMax; // We may need to reposition ourselves when the visible buttons change. // (CSS properly adjusts width and height, but the tray may be stuck in the // middle of the screen.) window.addEventListener('software-button-enabled', this.requestFuturePosition.bind(this)); window.addEventListener('software-button-disabled', this.requestFuturePosition.bind(this)); // When the window resizes (i.e. the device's orientation changes), we need // to immediately update the dimensions of our element; otherwise the tray // may appear stuck in the middle of the screen after the resize. window.addEventListener('resize', () => { this.markPosition('resize'); }); // Begin completely closed. this.markPosition('resize'); this.close(true); } // We don't know that the element's scroll position has completely stopped until // we see the scroll position not moving after a bit of time. Whenever we think // the scroll position is changing, we set a timer to check the position again // in the near future. See for a scrollend event // proposal which could alleviate this issue. // // This value must be large enough that we expect to see a 'scroll' event // in response to a .scrollTo() call within this amount of time, otherwise // we will force the tray to close/open immediately. const FUTURE_POSITION_DELAY_MS = 300; UtilityTrayMotion.prototype = { /** * @member {string} state * "opening", "closing", "open", or "closed" */ get state() { return this._state; }, /** * Update the state, setting the state as a class on this.el, and dispatching * the "tray-motion-state" event to notify observers. */ _setState(newState) { var oldState = this._state; this._state = newState; if (oldState !== newState) { this.el.classList.remove(oldState); this.el.classList.add(newState); this.el.dispatchEvent(new CustomEvent('tray-motion-state', { detail: { previousValue: oldState, value: newState } })); } }, /** * The velocity of the current scroll, in pixels per millisecond. Typical * values are mostly between -2 and 2, with larger values for faster flings. * Avoid relying on this number if possible. We take an average value here, * because the instantaneous scroll velocity may be unreliable, due to the * unpredictable timing of scroll events. */ get velocity() { return this._velocityAverager.currentAverage; }, /** * @member {number} percentVisible * A value between 0 (fully closed) and 1 (fully open), inclusive. */ get percentVisible() { return 1 - (this.position / this.maxPosition); }, /** * The desired scroll offset (i.e. between 0 and this.el.scrollTopMax), * i.e. where the tray should rest if it is not currently being touched. * Remember that when the position is zero, the tray is visible; when position * is equal to scrollTopMax, the tray is hidden. * * If the velocity is zero, just continue to follow the orders of this.state. */ get desiredPosition() { var velocity = this.velocity; if (velocity < 0) { return 0; } else if (velocity > 0) { return this.maxPosition; } else if (this.state === 'closing') { return this.maxPosition; } else if (this.state === 'opening') { return 0; } else { return this.position; } }, /** * Schedule a near-future position check, usually because we just saw a touch * or scroll event, and need to revisit the scroll state momentarily to ensure * we respond to the touch/scroll events appropriately. */ requestFuturePosition() { this.cancelRequestFuturePosition(); this._rfp = setTimeout(() => { this._rfp = null; this.markPosition('timeout'); }, FUTURE_POSITION_DELAY_MS); }, cancelRequestFuturePosition() { if (this._rfp) { clearTimeout(this._rfp); this._rfp = null; } }, isVelocityFastEnoughToFinishTheScrollWithoutHelp() { // Calculate how far we think the tray will slide, given the current // estimated velocity, accounting for some friction. // The system friction is defined as a pref here: // // If the system friction changes, we should ideally update this value, but // we likely wouldn't see any ill effects. We're guessing at velocity // already, which makes this only an estimate at best. var friction = 0.002; var pixelsLeftToTraverse = Math.abs(this.desiredPosition - this.position); // This formula (the integral of the deceleration curve) provides the // estimated sliding distance, as described in platform here: // var slidingDistance = Math.abs(-this.velocity / Math.log(1.0 - friction)) | 0; return pixelsLeftToTraverse < slidingDistance; }, /** * Note the current scroll position, whether triggered by a touch, scroll, * or timeout. Because we leave the core scroll behavior up to the platform, * we can handle all of these events similarly: calculate our current * position and velocity, update state, and decide if we need to `scrollTo()` * to push the tray into its final position. * * @param {string} source * The reason we're marking this position change. */ markPosition(source) { this.requestFuturePosition(); var previousVelocity = this.velocity; var previousPosition = this.position; var previousMaxPosition = this.maxPosition; this.position = this.el.scrollTop; this.maxPosition = this.el.scrollTopMax; this._velocityAverager.addPoint(Date.now(), this.position); if ((previousVelocity > 0 && this.velocity < 0) || (previousVelocity < 0 && this.velocity > 0)) { this._lastTouchTime = Date.now(); } // Check for window resizes and layout changes: if (previousMaxPosition !== this.maxPosition) { if (this.state === 'closed' || this.state === 'closing') { this.close(true); } else { this.open(true); } } var notRecentlyTouched = (this._lastTouchTime + FUTURE_POSITION_DELAY_MS < Date.now()); // Are we stuck or moving really slow? if ((source === 'timeout' || (source === 'scroll' && !this.isVelocityFastEnoughToFinishTheScrollWithoutHelp())) && notRecentlyTouched && !this.isTouching) { if (this.desiredPosition !== this.position) { if (this.state === 'opening') { this.open(); } else { this.close(); } } else { this._setState(this.state === 'opening' || this.state === 'open' ? 'open' : 'closed'); this.cancelRequestFuturePosition(); } } else { if (this.velocity < 0) { this._setState('opening'); } else if (this.velocity > 0) { this._setState('closing'); } } // For aesthetic reasons, if the tray is almost fully open (as position // approaches zero), start dispatching motion events to allow UtilityTray // to show or hide the fixed-position footer. Without this event, // the footer must wait until this.state === 'open', which takes a while // due to overscroll effects. var CLOSE_TO_BOTTOM_PX = 100; if (previousPosition < CLOSE_TO_BOTTOM_PX || this.position < CLOSE_TO_BOTTOM_PX) { this.el.dispatchEvent( new CustomEvent('tray-motion-footer-position')); } }, /** * We don't know how long it will take for the system to respond to our * scrollTo 'smooth' request. The FUTURE_POSITION_DELAY_MS timeout provides a * good estimate, but in the case of a very unresponsive system, we don't want * to fall into a loop of: * * 1. Requesting `scrollTo` * 2. Timeout fires but element hasn't scrolled yet; call scrollTo again * * The right action, if we aren't seeing action from 'behavior: smooth', * is to just fall back on an instant change, in which case we'll detect * with certainty that the element has finished scrolling to the destination. */ reliablyScrollTo(top, immediately) { var prevOffset = this._previouslyRequestedOffset; this._previouslyRequestedOffset = top; if (prevOffset === top) { immediately = true; } // XXX: Some sort of rounding error causes 1px of the tray to remain visible // in certain configurations, but this fixes it: if (top === this.maxPosition) { top++; } this.el.scrollTo({ left: 0, top: top, behavior: immediately ? 'auto' : 'smooth' }); }, /** * Open the tray if not already opened. * * @param {boolean} immediately */ open(immediately) { this._setState('opening'); this._velocityAverager.reset(); this.markPosition('force'); this.reliablyScrollTo(0, immediately); }, /** * Close the tray if not already closed. * * @param {boolean} immediately */ close(immediately) { this._setState('closing'); this._velocityAverager.reset(); this.markPosition('force'); this.reliablyScrollTo(this.maxPosition, immediately); }, _ontouch(evt) { if (Service.query('locked') || Service.query('isFtuRunning')) { evt.preventDefault(); return; } this.isTouching = (evt.touches.length > 0); this.currentTouch = evt.touches && evt.touches[0]; if (evt.type === 'touchstart') { this._velocityAverager.reset(); } if (this.isTouching) { this._lastTouchTime = Date.now(); } requestAnimationFrame(this.markPosition.bind(this, evt.type)); }, _onscroll(evt) { this._previouslyRequestedOffset = null; // We only need to track scroll events if the user isn't touching; otherwise // we would see a 'scroll' event corresponding with every 'touchmove' event. if (!this.isTouching) { requestAnimationFrame(this.markPosition.bind(this, 'scroll')); } } }; /** * Computes a windowed average, excluding points a certain amount of time in * the past. Usage: * * var averager = new TimeWindowedAverager(400); * averager.addPoint(Date.now() - 100, 0); * averager.addPoint(Date.now(), 50); * console.log(averager.currentAverage); // => 0.5 * * @param {int} maxTimeDelta * The maximum number of milliseconds to include in the average. Values older * than (now - maxTimeDelta) are discarded. */ function TimeWindowedAverager(maxTimeDelta) { /** * The current average value of all points. */ this.currentAverage = 0; this._maxTimeDelta = maxTimeDelta; this._points = []; } TimeWindowedAverager.prototype = { /** * Reset the current window (leading to an instantaneous average of zero). */ reset() { this._points.length = 0; }, /** * Add a point (e.g. a velocity) at the given time to the averaging * calculation. (If the point is too old, it will be ignored). Old points * will be removed. */ addPoint(time, value) { var now = Date.now(); this._points.push({ time, value }); // Discard points that are too old. while (this._points[0].time < now - this._maxTimeDelta) { this._points.shift(); } // If we have enough points, compute a windowed average (distance / time). if (this._points.length < 2) { this.currentAverage = 0; } else { var totalDistance = 0; var totalTime = 0; for (var i = 1, len = this._points.length; i < len; i++) { var a = this._points[i - 1], b = this._points[i]; totalDistance += b.value - a.value; totalTime += b.time - a.time; } this.currentAverage = totalDistance / totalTime; } } }; exports.UtilityTrayMotion = UtilityTrayMotion; })(window); // end function wrapper