VulpesOS/gaia/apps/system/js/utility_tray_motion.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

475 lines
16 KiB
JavaScript
Executable file

'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 <https://bugzil.la/1172171> 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:
// <http://mxr.mozilla.org/mozilla-central/source/gfx/thebes/gfxPrefs.h#159>
// 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:
// <http://mxr.mozilla.org/mozilla-central/source/gfx/layers/apz/src/AsyncPanZoomController.cpp#2164>
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