diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md new file mode 100644 index 0000000000000..12eb02fc42391 --- /dev/null +++ b/.changeset/gesture-pan.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts new file mode 100644 index 0000000000000..136fbcfe094f3 --- /dev/null +++ b/packages/core/src/gestures/PanGestureRecognizer.ts @@ -0,0 +1,410 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PanGestureRecognizer.ts + * @input Touches from a GestureArena, each with a probe of the native + * scrollers under it + * @output Exports PanGestureRecognizer (UIPanGestureRecognizer), its options + * and direction type + * @position Continuous recognizer of packages/core/src/gestures; the base of + * ScreenEdgePanGestureRecognizer + * + * Begins once the touches' centroid has travelled `hysteresis` px along + * either axis (UIKit applies it per axis, not as a distance) with between + * `minimumNumberOfTouches` and `maximumNumberOfTouches` fingers down, in the + * allowed `direction`. As in UIKit, `translation()` is reset when the pan + * begins, so the travel spent on the threshold is not part of it and a + * dragged surface does not jump; `totalTranslation()` is the movement since + * the fingers landed, and `travelAtBegan` what the threshold took. + * `velocity()` is UIKit's own filter (see VelocityTracker), fed one sample + * per event; the lift's position updates neither, as in UIKit. Adding or + * lifting a finger never jumps the translation: the centroid is re-based so + * the gesture continues where it was. Once moving, fingers past the maximum + * are ignored, so a second finger never breaks a one-finger pan. + * + * NATIVE SCROLL COORDINATION, for a pan over content the browser scrolls + * (a sheet over its own list): + * + * - `scrollEdge`: the pan begins only if no scroller under the touch could + * scroll for that movement — the finger landed at the edge and pulls away + * from it. The browser has not committed yet, so beginning claims the + * touch and the browser never scrolls. + * + * Who gets the drag is decided once, when it begins: a drag that began as + * the browser's scroll stays the browser's to the lift and rubber-bands at + * the content's edge, as a UIKit scroll view keeps it (`spec:AST-094` FR14). + */ + +import {UIKIT_PAN} from '../motion/uikit/gestures'; +import {centroid} from './geometry'; +import { + ALL_NATIVE_TOUCH, + GestureRecognizer, + isActive, + type GestureRecognizerOptions, + type NativeTouchBehavior, +} from './GestureRecognizer'; +import type {GestureEventContext, GestureTouch, Point} from './types'; +import {VelocityTracker} from './VelocityTracker'; + +export type PanDirection = + 'any' | 'horizontal' | 'vertical' | 'left' | 'right' | 'up' | 'down'; + +export interface PanGestureRecognizerOptions extends GestureRecognizerOptions { + /** @default 1 */ + minimumNumberOfTouches?: number; + /** @default Infinity */ + maximumNumberOfTouches?: number; + hysteresis?: number; + /** Movement that may begin the pan; anything else fails it. @default 'any' */ + direction?: PanDirection; + /** + * For a pan limited to an axis: how much the movement along the axis must + * exceed the movement across it to begin. Above 1, movement that is still + * between the two lines when the pan reaches its hysteresis fails it: a pan + * that competes with native scrolling decides within 10 px + * (`spec:AST-094` FR19). Below 1, the pan claims drags up to + * atan(1 / axisRatio) off its axis. + * @default 1 + */ + axisRatio?: number; + /** + * With `direction: 'any'`, report movement along the axis that dominated + * at the start only (UIScrollView's directional lock). + */ + lockAxis?: boolean; + /** Begin only when the native scrollers under the touch are at the edge. */ + scrollEdge?: boolean; +} + +const ZERO: Point = {x: 0, y: 0}; + +const DIRECTION_VECTORS: Partial> = { + left: {x: -1, y: 0}, + right: {x: 1, y: 0}, + up: {x: 0, y: -1}, + down: {x: 0, y: 1}, +}; + +export class PanGestureRecognizer extends GestureRecognizer { + minimumNumberOfTouches: number; + maximumNumberOfTouches: number; + hysteresis: number; + direction: PanDirection; + axisRatio: number; + lockAxis: boolean; + scrollEdge: boolean; + /** The axis a `lockAxis` pan locked to when it began. */ + lockedAxis: 'x' | 'y' | null = null; + /** The movement the begin threshold took (excluded from `translation()`). */ + travelAtBegan: Point = ZERO; + + private readonly tracker: VelocityTracker; + /** The lift's event time once the pan ended: its release (AST-100 FR2). */ + private releaseTime: number | undefined = undefined; + /** The centroid where the gesture started, in client px. */ + private origin: Point = ZERO; + /** Re-basing that keeps the translation continuous across finger changes. */ + private adjust: Point = ZERO; + /** Translation since touch-down, before `setTranslation` offsets. */ + private raw: Point = ZERO; + private offset: Point = ZERO; + + constructor(options: PanGestureRecognizerOptions = {}) { + super(options); + this.minimumNumberOfTouches = options.minimumNumberOfTouches ?? 1; + this.maximumNumberOfTouches = + options.maximumNumberOfTouches ?? Number.POSITIVE_INFINITY; + this.hysteresis = options.hysteresis ?? UIKIT_PAN.hysteresis.value; + this.direction = options.direction ?? 'any'; + this.axisRatio = options.axisRatio ?? 1; + this.lockAxis = options.lockAxis ?? false; + this.scrollEdge = options.scrollEdge ?? false; + this.tracker = new VelocityTracker(); + } + + /** + * Movement since the pan began (or since `setTranslation`), client px; the + * movement since touch-down while it is still possible. + */ + translation(): Point { + return this.locked({ + x: this.raw.x + this.offset.x, + y: this.raw.y + this.offset.y, + }); + } + + /** + * Change the allowed direction for the next gesture (a pan in progress + * keeps the one it began with). The element's touch-action is derived + * when the recognizer is attached: switching between directions that + * leave the browser the same touches (up and down) needs nothing more. + */ + setDirection(direction: PanDirection): void { + this.direction = direction; + } + + /** UIKit's `setTranslation(_:in:)`: make the current translation read `value`. */ + setTranslation(value: Point): void { + this.offset = {x: value.x - this.raw.x, y: value.y - this.raw.y}; + } + + /** Movement since the fingers landed, threshold included, client px. */ + totalTranslation(): Point { + return this.raw; + } + + /** + * Velocity in px/s, UIKit's way, for a dragged surface. At the release it + * is 0 when the finger rested longer than the stillness interval before + * lifting, or moved fewer than twice (spec:AST-100 FR2). + */ + velocity(): Point { + return this.locked(this.tracker.velocity(this.releaseTime)); + } + + /** + * A scrolling surface's release velocity in px/s (paging, a wheel): the + * scroll-view blend of the pan's last two values, with the same release + * rules (architecture:interaction-modality INV10). + */ + scrollVelocity(): Point { + return this.locked( + this.tracker.scrollVelocity( + this.releaseTime ?? this.tracker.lastSample?.time ?? 0, + ), + ); + } + + /** + * The browser keeps the axis a two-way pan does not use. A one-way pan, + * and a pan that coordinates with native scrolling, leave the browser + * everything: the other direction still scrolls (a toast dragged away from + * its edge scrolls the page), and the pan claims its own direction by + * cancelling `touchmove` when it begins, below the distance at which the + * browser decides to scroll. Pinch-zoom stays the browser's. + */ + override get nativeTouchBehavior(): NativeTouchBehavior { + if (this.scrollEdge || DIRECTION_VECTORS[this.direction] != null) { + return ALL_NATIVE_TOUCH; + } + const horizontal = + this.direction === 'horizontal' || + this.direction === 'left' || + this.direction === 'right'; + const vertical = + this.direction === 'vertical' || + this.direction === 'up' || + this.direction === 'down'; + return { + panX: vertical, + panY: horizontal, + pinchZoom: true, + doubleTapZoom: false, + }; + } + + protected override acceptsTouch(touch: GestureTouch): boolean { + if ( + isActive(this.state) && + this.numberOfTouches >= this.maximumNumberOfTouches + ) { + // A moving pan keeps its fingers: an extra one never fails it. + return false; + } + return super.acceptsTouch(touch); + } + + protected override touchesBegan( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + if (this.numberOfTouches === touches.length) { + this.origin = centroid(this.touches.values()); + this.adjust = ZERO; + this.raw = ZERO; + this.offset = ZERO; + this.tracker.reset(); + this.tracker.add(this.origin.x, this.origin.y, event.time); + } else { + this.rebase(new Set(touches.map(touch => touch.id)), 'added'); + } + if ( + this.state === 'possible' && + this.numberOfTouches > this.maximumNumberOfTouches + ) { + this.setState('failed'); + } + } + + protected override touchesMoved( + _touches: ReadonlyArray, + event: GestureEventContext, + ): void { + this.track(event.time); + if (this.state === 'possible') { + this.tryBegin(); + } else if (isActive(this.state)) { + this.setState('changed'); + } + } + + protected override touchesEnded( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + // UIKit reads neither the translation nor the velocity from the lift; the + // lift's time only decides whether the finger was still (AST-100 FR2). + const remaining = this.numberOfTouches - touches.length; + if (isActive(this.state)) { + if (remaining < this.minimumNumberOfTouches) { + this.releaseTime = event.time; + this.setState('ended'); + return; + } + this.rebase(new Set(touches.map(touch => touch.id)), 'removed'); + return; + } + if (remaining === 0 || remaining < this.minimumNumberOfTouches) { + this.setState('failed'); + return; + } + this.rebase(new Set(touches.map(touch => touch.id)), 'removed'); + } + + protected override reset(): void { + this.lockedAxis = null; + this.travelAtBegan = ZERO; + this.origin = ZERO; + this.adjust = ZERO; + this.raw = ZERO; + this.offset = ZERO; + this.releaseTime = undefined; + this.tracker.reset(); + } + + /** + * What this movement means for a pan limited to a direction: begin, fail, + * or undecided (between `axisRatio`'s lines), which fails it too once it + * reaches its hysteresis. + */ + protected classify(translation: Point): 'begin' | 'fail' | 'wait' { + if (this.direction === 'any') { + return 'begin'; + } + const isHorizontal = + this.direction === 'horizontal' || + this.direction === 'left' || + this.direction === 'right'; + const along = isHorizontal ? translation.x : translation.y; + const across = Math.abs(isHorizontal ? translation.y : translation.x); + const magnitude = Math.abs(along); + if (magnitude > this.axisRatio * across) { + // The axis is decided; a one-way pan also needs the right sign. + const vector = DIRECTION_VECTORS[this.direction]; + const sign = vector == null ? 0 : vector.x + vector.y; + return sign === 0 || Math.sign(along) === sign ? 'begin' : 'fail'; + } + return across * Math.min(1, this.axisRatio) > magnitude ? 'fail' : 'wait'; + } + + private tryBegin(): void { + const count = this.numberOfTouches; + if (count < this.minimumNumberOfTouches) { + return; + } + if (count > this.maximumNumberOfTouches) { + this.setState('failed'); + return; + } + const moved = this.raw; + if (Math.max(Math.abs(moved.x), Math.abs(moved.y)) < this.hysteresis) { + return; + } + const verdict = this.classify(moved); + if (verdict === 'fail') { + this.setState('failed'); + return; + } + if (verdict === 'wait') { + // Past its 10 px begin and still no clear axis: a pan that competes + // with native scrolling must decide by then (`spec:AST-094` FR19), so + // it leaves the touch to the browser. + this.setState('failed'); + return; + } + if (this.scrollEdge && this.nativeCanScroll(moved.x, moved.y)) { + // The content under the finger still scrolls this way: it is a scroll, + // the browser's to the lift (FR14). + this.setState('failed'); + return; + } + if (this.lockAxis && this.direction === 'any') { + this.lockedAxis = Math.abs(moved.x) >= Math.abs(moved.y) ? 'x' : 'y'; + } + this.startFromHere(); + this.setState('began'); + } + + /** + * Leave the threshold's travel out of the translation, as UIKit does at + * .began: up to `hysteresis` px per axis, so a slow drag starts at zero and + * a step that overshoots keeps the overshoot (measured: a slow diagonal + * that crossed at (10.67, 10.67) reads (0.67, 0.67); a 3:1 drag crossing at + * (11, 3.67) reads (1, 0)). Some fast first moves read zero in UIKit; that + * case is not modelled. + */ + private startFromHere(): void { + const limit = this.hysteresis; + const swallowed = { + x: Math.sign(this.raw.x) * Math.min(Math.abs(this.raw.x), limit), + y: Math.sign(this.raw.y) * Math.min(Math.abs(this.raw.y), limit), + }; + this.travelAtBegan = swallowed; + this.offset = {x: -swallowed.x, y: -swallowed.y}; + } + + private nativeCanScroll(dx: number, dy: number): boolean { + for (const touch of this.touches.values()) { + if (touch.scroll?.canScroll(dx, dy) === true) { + return true; + } + } + return false; + } + + private track(time: number): void { + const current = centroid(this.touches.values()); + const virtual = { + x: current.x + this.adjust.x, + y: current.y + this.adjust.y, + }; + // UIKit's filter runs once per event. + this.tracker.add(virtual.x, virtual.y, time); + this.raw = {x: virtual.x - this.origin.x, y: virtual.y - this.origin.y}; + } + + /** Keep the translation continuous when fingers join or leave. */ + private rebase( + changedIds: ReadonlySet, + kind: 'added' | 'removed', + ): void { + const all = centroid(this.touches.values()); + const others = centroid(this.touches.values(), changedIds); + const [before, after] = kind === 'added' ? [others, all] : [all, others]; + this.adjust = { + x: this.adjust.x + before.x - after.x, + y: this.adjust.y + before.y - after.y, + }; + } + + private locked(value: Point): Point { + if (this.lockedAxis === 'x') { + return {x: value.x, y: 0}; + } + if (this.lockedAxis === 'y') { + return {x: 0, y: value.y}; + } + return value; + } +} diff --git a/packages/core/src/gestures/VelocityTracker.ts b/packages/core/src/gestures/VelocityTracker.ts new file mode 100644 index 0000000000000..c627282c8c25f --- /dev/null +++ b/packages/core/src/gestures/VelocityTracker.ts @@ -0,0 +1,194 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file VelocityTracker.ts + * @input Timestamped positions of a moving contact, and the release time + * @output Exports VelocityTracker, the one velocity tracker every continuous + * recognizer uses (architecture:interaction-modality INV10, + * spec:AST-100 FR1, FR2) + * @position Pure math under packages/core/src/gestures; no DOM + * + * What a release hands to a spring or a deceleration: the finger's velocity + * when it lifted, in px per SECOND (UIKit's unit for `velocity(in:)`). + * + * One filter, iOS's measured blend, updated on each MOVED sample (a sample + * at the newest position is not motion): + * + * - A pan's velocity is 0.2 times the newest interval's velocity plus 0.8 + * times the previous interval's (an interval runs from one moved sample to + * the next; the touch-down counts as the first). With one interval so far + * it is that interval, as UIKit reports it. It reproduces every value + * UIKit reported on six HID streams, and like UIKit's it reacts a frame + * late to an acceleration. + * - A scrolling surface's release (paging, a wheel) is 0.25 times the newest + * pan value plus 0.75 times the previous one. + * + * A release (`velocity(releaseTime)`, `scrollVelocity(releaseTime)`) reads 0 + * when the contact was still for longer than the stillness interval before + * lifting, the one difference from iOS, whose filter never decays and so + * flings after a rest; and 0 with fewer than two moved samples. + * + * Feed it one sample per input event: coalesced samples would change its + * timing. + * + * SYNC: When modified, update VelocityTracker.test.ts. + */ + +import {UIKIT_VELOCITY} from '../motion/uikit/gestures'; +import type {Point, TouchSample} from './types'; + +export interface VelocityTrackerOptions { + /** + * A contact that has not moved for longer than this (ms) before its + * release releases at 0 (spec:AST-100 FR2). + */ + stillness?: number; +} + +const ZERO: Point = {x: 0, y: 0}; +const PAN_CURRENT_WEIGHT = UIKIT_VELOCITY.currentWeight.value; +const SCROLL_CURRENT_WEIGHT = UIKIT_VELOCITY.scrollReleaseWeight.value; + +function blend(weight: number, current: Point, previous: Point): Point { + return { + x: weight * current.x + (1 - weight) * previous.x, + y: weight * current.y + (1 - weight) * previous.y, + }; +} + +export class VelocityTracker { + private readonly stillness: number; + /** The newest sample added, moved or not. */ + private newest: TouchSample | null = null; + /** + * The newest moved samples, oldest first (the touch-down counts as the + * first); the filter reads the last three. + */ + private moved: TouchSample[] = []; + /** Moved samples since the touch-down. */ + private moves = 0; + /** The newest pan value, and the one before it. */ + private pan: Point = ZERO; + private previousPan: Point = ZERO; + + constructor(options: VelocityTrackerOptions = {}) { + this.stillness = options.stillness ?? UIKIT_VELOCITY.stillness.value; + } + + /** Forget every sample (a new gesture). */ + reset(): void { + this.newest = null; + this.moved = []; + this.moves = 0; + this.pan = ZERO; + this.previousPan = ZERO; + } + + /** + * Record a position. Timestamps must not go backwards (an older sample is + * ignored), and a moved sample at the newest moved sample's timestamp + * replaces it. A sample at the newest position is not motion: it changes + * no velocity. + */ + add(x: number, y: number, time: number): void { + if (this.newest != null && time < this.newest.time) { + return; + } + this.newest = {x, y, time}; + const last = this.moved[this.moved.length - 1]; + if (last != null && x === last.x && y === last.y) { + return; + } + if (last != null && time === last.time) { + this.moved[this.moved.length - 1] = {x, y, time}; + } else { + this.moved.push({x, y, time}); + if (this.moved.length > 3) { + this.moved.shift(); + } + if (last != null) { + this.moves += 1; + this.previousPan = this.pan; + } + } + this.pan = panVelocity(this.moved); + } + + /** Record every sample in order. */ + addSamples(samples: ReadonlyArray): void { + for (const sample of samples) { + this.add(sample.x, sample.y, sample.time); + } + } + + /** The newest sample, if any. */ + get lastSample(): TouchSample | null { + return this.newest; + } + + /** + * The pan's velocity in px/s. Pass the release's event time to read the + * release velocity of a dragged surface: 0 after a rest longer than the + * stillness interval, or with fewer than two moved samples (AST-100 FR2). + */ + velocity(releaseTime?: number): Point { + if (releaseTime != null && !this.releases(releaseTime)) { + return ZERO; + } + return this.pan; + } + + /** + * A scrolling surface's release velocity in px/s (paging, a wheel): 0.25 + * times the newest pan value plus 0.75 times the previous one, with the + * same stillness and two-moved-sample rules (AST-100 FR2). + */ + scrollVelocity(releaseTime: number): Point { + if (!this.releases(releaseTime)) { + return ZERO; + } + return blend(SCROLL_CURRENT_WEIGHT, this.pan, this.previousPan); + } + + private releases(releaseTime: number): boolean { + const last = this.moved[this.moved.length - 1]; + return ( + this.moves >= 2 && + last != null && + releaseTime - last.time <= this.stillness + ); + } +} + +/** + * UIPanGestureRecognizer's filter over the newest moved samples: 0.2 x the + * newest interval's velocity + 0.8 x the previous one's; one interval so far + * is reported whole, as UIKit does. + */ +function panVelocity(moved: ReadonlyArray): Point { + const n = moved.length; + if (n < 2) { + return ZERO; + } + const current = pairVelocity(moved[n - 2], moved[n - 1]); + if (n === 2) { + return current; + } + return blend( + PAN_CURRENT_WEIGHT, + current, + pairVelocity(moved[n - 3], moved[n - 2]), + ); +} + +/** Instantaneous velocity (px/s) between two samples, zero across no time. */ +function pairVelocity(start: TouchSample, end: TouchSample): Point { + const dt = end.time - start.time; + if (dt <= 0) { + return ZERO; + } + return { + x: ((end.x - start.x) / dt) * 1000, + y: ((end.y - start.y) / dt) * 1000, + }; +} diff --git a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts new file mode 100644 index 0000000000000..1cf18ec0b6549 --- /dev/null +++ b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PanGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output PanGestureRecognizer's state machine: the per-axis hysteresis and the translation without it, directions and axisRatio, the release velocity, axis lock, finger changes, setTranslation, cancellation + * @position Unit tests of packages/core/src/gestures/PanGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {createHarness, scrollProbe} from './harness'; + +describe('PanGestureRecognizer', () => { + it('begins past the hysteresis and leaves the threshold out of the translation', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + const hysteresis = UIKIT_GESTURES.pan.hysteresis.value; + h.finger(1).down(100, 100, 0, {recognizers: [pan]}); + h.finger(1).move(100 + hysteresis - 0.1, 100, 16); + expect(pan.state).toBe('possible'); + h.finger(1).move(100 + hysteresis + 2, 100, 32); + expect(pan.state).toBe('began'); + // UIKit: the overshoot past the threshold, not the travel since touch-down. + expect(pan.translation()).toEqual({x: 2, y: 0}); + expect(pan.travelAtBegan).toEqual({x: hysteresis, y: 0}); + expect(pan.totalTranslation()).toEqual({x: hysteresis + 2, y: 0}); + h.finger(1).move(150, 120, 48); + expect(pan.state).toBe('changed'); + expect(pan.translation()).toEqual({x: 40, y: 20}); + expect(pan.totalTranslation()).toEqual({x: 50, y: 20}); + }); + + it('applies the hysteresis per axis, as UIKit does', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + // 10.6 px away on the diagonal, but under 10 on each axis: not yet. + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(7.5, 7.5, 16); + expect(pan.state).toBe('possible'); + // UIKit's slow diagonal: began at (10.67, 10.67), translation (0.67, 0.67). + h.finger(1).move(10.5, 10.5, 32); + expect(pan.state).toBe('began'); + expect(pan.translation()).toEqual({x: 0.5, y: 0.5}); + // A 3:1 drag crossing at (11, 3.67) reads (1, 0) in UIKit. + const slope = createHarness(); + const other = new PanGestureRecognizer({name: 'other'}); + slope + .finger(1) + .down(0, 0, 0, {recognizers: [other]}) + .move(9, 3, 16) + .move(11, 3.5, 32); + expect(other.translation()).toEqual({x: 1, y: 0}); + }); + + it('ends with the release velocity in px/s', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + let releaseVelocity = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + releaseVelocity = r.velocity(); + } + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [pan]}); + // 1 px/ms = 1000 px/s, sampled every 8 ms. + for (let t = 8; t <= 160; t += 8) { + finger.move(t, 0, t); + } + finger.up(168); + expect(releaseVelocity.x).toBeCloseTo(1000, 0); + expect(releaseVelocity.y).toBeCloseTo(0, 5); + }); + + it('releases at 0 after a rest longer than the stillness interval (AST-100 FR2)', () => { + const stillness = UIKIT_GESTURES.velocity.stillness.value; + const release = (liftAt: number) => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + let releaseVelocity = {x: -1, y: -1}; + pan.addListener(r => { + if (r.state === 'ended') { + releaseVelocity = r.velocity(); + } + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [pan]}); + finger.drag(200, 0, 100, 10); + // A still finger sends no events until it lifts. + finger.up(liftAt); + return releaseVelocity; + }; + // Lifted within the interval: the moving value, as UIKit reports it. + expect(release(100 + stillness).x).toBeCloseTo(2000, 6); + // Rested longer: no flick, where UIKit would still fling. + expect(release(100 + stillness + 60)).toEqual({x: 0, y: 0}); + }); + + it('fails a drag against its direction', () => { + const vertical = createHarness(); + const horizontal = new PanGestureRecognizer({ + name: 'h', + direction: 'horizontal', + }); + vertical + .finger(1) + .down(0, 0, 0, {recognizers: [horizontal]}) + .drag(3, 20, 50); + expect(horizontal.state).toBe('failed'); + + const up = createHarness(); + const down = new PanGestureRecognizer({name: 'down', direction: 'down'}); + up.finger(1) + .down(0, 100, 0, {recognizers: [down]}) + .drag(0, -30, 50); + expect(down.state).toBe('failed'); + }); + + it('decides by 10 px with an axisRatio above 1: between the lines it fails (spec:AST-094 FR19), on its axis it begins', () => { + const between = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + direction: 'vertical', + axisRatio: 2, + }); + between + .finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(8, 12, 16); + // 12 is not more than 2 x 8, and not less than 8: undecided past 10 px. + expect(pan.state).toBe('failed'); + expect(between.claims).toEqual([]); + + const along = createHarness(); + const steep = new PanGestureRecognizer({ + name: 'steep', + direction: 'vertical', + axisRatio: 2, + }); + along + .finger(1) + .down(0, 0, 0, {recognizers: [steep]}) + .move(4, 12, 16); + expect(steep.state).toBe('began'); // 12 > 2 x 4 + + const diagonal = createHarness(); + const side = new PanGestureRecognizer({ + name: 'side', + direction: 'vertical', + axisRatio: 2, + }); + diagonal + .finger(1) + .down(0, 0, 0, {recognizers: [side]}) + .move(10, 9, 16); + expect(side.state).toBe('failed'); // more across than along + }); + + it('claims drags up to atan(1 / axisRatio) off its axis below 1', () => { + const steep = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + direction: 'horizontal', + axisRatio: 0.5, + }); + // 60 degrees off horizontal: 10 across 17 along y; 10 > 0.5 x 17. + steep + .finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(10, 17, 16); + expect(pan.state).toBe('began'); + + const steeper = createHarness(); + const other = new PanGestureRecognizer({ + name: 'other', + direction: 'horizontal', + axisRatio: 0.5, + }); + // 68 degrees: 8 < 0.5 x 20. + steeper + .finger(1) + .down(0, 0, 0, {recognizers: [other]}) + .move(8, 20, 16); + expect(other.state).toBe('failed'); + }); + + it('decides the sign of a one-way pan once its axis is decided', () => { + const h = createHarness(); + const down = new PanGestureRecognizer({ + name: 'down', + direction: 'down', + axisRatio: 1.2, + }); + // 9 along, 8 across: not 1.2 times more, not less either: wait. + h.finger(1) + .down(0, 100, 0, {recognizers: [down]}) + .move(8, 109, 16); + expect(down.state).toBe('possible'); + // The axis is now clearly vertical, and the movement is up. + h.finger(1).move(8, 70, 32); + expect(down.state).toBe('failed'); + }); + + it('locks to the dominant axis when asked', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan', lockAxis: true}); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(12, 4, 16) + .move(40, 30, 32); + expect(pan.lockedAxis).toBe('x'); + expect(pan.translation()).toEqual({x: 30, y: 0}); + }); + + it('ignores a second finger once moving, so it never breaks the first', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + maximumNumberOfTouches: 1, + }); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(20, 0, 16); + expect(pan.state).toBe('began'); + h.finger(2).down(200, 200, 30, {recognizers: [pan]}); + expect(pan.numberOfTouches).toBe(1); + h.finger(2).move(260, 260, 40); + h.finger(1).move(30, 0, 48); + expect(pan.translation()).toEqual({x: 20, y: 0}); + h.finger(2).up(60); + expect(pan.state).toBe('changed'); + h.finger(1).up(70); + expect(pan.state).toBe('possible'); + expect(h.log).toContain('pan:ended@70'); + }); + + it('fails when a second finger lands before it began', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + maximumNumberOfTouches: 1, + }); + h.finger(1).down(0, 0, 0, {recognizers: [pan]}); + h.finger(2).down(50, 0, 10, {recognizers: [pan]}); + expect(pan.state).toBe('failed'); + }); + + it('keeps the translation continuous when fingers join and leave', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(30, 0, 16); + expect(pan.translation()).toEqual({x: 20, y: 0}); + // A second finger far away moves the centroid by 100 px: no jump. + h.finger(2).down(230, 0, 20, {recognizers: [pan]}); + expect(pan.translation()).toEqual({x: 20, y: 0}); + h.finger(1).move(40, 0, 32); + h.finger(2).move(240, 0, 32); + expect(pan.translation()).toEqual({x: 30, y: 0}); + h.finger(2).up(40); + expect(pan.translation()).toEqual({x: 30, y: 0}); + h.finger(1).move(50, 0, 48); + expect(pan.translation()).toEqual({x: 40, y: 0}); + }); + + it('waits for the minimum number of touches', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + minimumNumberOfTouches: 2, + }); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(40, 0, 16); + expect(pan.state).toBe('possible'); + h.finger(2).down(100, 0, 20, {recognizers: [pan]}); + h.finger(1).move(60, 0, 32); + expect(pan.state).toBe('began'); + expect(h.log).toEqual(['pan:began@32']); + }); + + it('can re-base its translation (setTranslation)', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(30, 0, 16); + pan.setTranslation({x: 0, y: 0}); + h.finger(1).move(35, 2, 32); + expect(pan.translation()).toEqual({x: 5, y: 2}); + }); + + it('is cancelled by the system', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(30, 0, 16); + h.finger(1).cancel(32); + expect(h.log).toEqual([ + 'pan:began@16', + 'pan:cancelled@32', + 'pan:possible@32', + ]); + }); + + it('claims nothing before it begins at 10 pt: a 5–9 pt drag leaves the touch to the browser (spec:AST-094 FR17)', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan', direction: 'up'}); + const finger = h.finger(1).down(0, 100, 0, {recognizers: [pan]}); + for (const [y, time] of [ + [95, 16], + [93, 32], + [91, 48], + ] as const) { + finger.move(0, y, time); + expect(pan.state).toBe('possible'); + expect(h.claims).toEqual([]); + } + finger.move(0, 90, 64); + expect(pan.state).toBe('began'); + expect(h.claims).toEqual([1]); + finger.up(80); + }); +}); diff --git a/packages/core/src/gestures/__tests__/VelocityTracker.test.ts b/packages/core/src/gestures/__tests__/VelocityTracker.test.ts new file mode 100644 index 0000000000000..db48b97584c72 --- /dev/null +++ b/packages/core/src/gestures/__tests__/VelocityTracker.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file VelocityTracker.test.ts + * @input Synthetic finger traces with known velocities + * @output UIKit's filter against the velocities UIKit reported on recorded + * touch streams; steady motion, a hard acceleration, the release rules + * (stillness, two moved samples), moved-only updates and the scroll-view + * release blend (architecture:interaction-modality INV10, spec:AST-100 FR2) + * @position Unit tests of packages/core/src/gestures/VelocityTracker.ts + */ + +import {describe, expect, it} from 'vitest'; +import {VelocityTracker} from '../VelocityTracker'; + +/** Feed `position(t)` sampled every `step` ms over [0, end]. */ +function trace( + tracker: VelocityTracker, + position: (t: number) => number, + end: number, + step = 8, +): void { + for (let t = 0; t <= end; t += step) { + tracker.add(position(t), 0, t); + } +} + +/** + * Touch streams UIKit's UIPanGestureRecognizer reported on (iOS 26.2 + * simulator, HID touches at 60 Hz): [ms since touch-down, y in pt] per event, + * and the velocity(in:).y UIKit reported at each event from the third on. + */ +const UIKIT_STREAMS = { + steady: { + samples: [ + [0, 840], + [57.702, 831.6666666666665], + [73.904, 815], + [91.286, 789.6666666666665], + [107.677, 773], + [125.123, 756.3333333333333], + [141.298, 739.6666666666665], + [157.709, 722.6666666666665], + [174.631, 706], + [191.78, 689.3333333333333], + [209.313, 672.6666666666666], + [226.956, 655.6666666666666], + [243.419, 639], + [260.223, 622.3333333333333], + [277.866, 605.6666666666666], + [294.774, 589], + [312.902, 572], + [333.081, 547], + [350.016, 530.3333333333333], + [368.475, 513.3333333333333], + [396.62, 488.33333333333326], + ], + velocities: [ + -321.3, -1114.4, -1369.3, -1004.5, -970.3, -1031.5, -1025.7, -982.3, + -967.6, -953.2, -973.3, -1008.3, -982.4, -952.8, -976.1, -998.0, -1187.9, + -971.5, -914.4, + ], + }, + accelerating: { + samples: [ + [0, 840], + [70.032, 838.6666666666665], + [93.493, 827.6666666666665], + [110.376, 790], + [131.223, 701], + [147.373, 640], + [164.226, 567.6666666666666], + [181.326, 484.33333333333326], + ], + velocities: [-109.0, -821.3, -2638.6, -4170.8, -3880.1, -4408.3], + }, +} as const; + +describe('VelocityTracker', () => { + it.each(Object.entries(UIKIT_STREAMS))( + 'uikit reproduces every velocity UIKit reported on the %s stream', + (_, {samples, velocities}) => { + const tracker = new VelocityTracker(); + const errors: number[] = []; + samples.forEach(([time, y], index) => { + tracker.add(0, y, time); + if (index >= 2) { + errors.push(Math.abs(tracker.velocity().y - velocities[index - 2])); + } + }); + expect(errors).toHaveLength(velocities.length); + // Within the rounding of the recorded values (0.1 pt/s) and of the + // timestamps (1 us). + expect(Math.max(...errors)).toBeLessThan(0.1); + }, + ); + + it('keeps the last moving value through a rest while the finger is down', () => { + const tracker = new VelocityTracker(); + trace(tracker, t => t * 2, 160); + // No event arrives while the finger rests: nothing changes the estimate. + expect(tracker.velocity().x).toBeCloseTo(2000, 6); + }); + + it('releases at 0 after a rest longer than the stillness interval (AST-100 FR2)', () => { + const tracker = new VelocityTracker({stillness: 40}); + trace(tracker, t => t * 2, 160); + expect(tracker.velocity(168).x).toBeCloseTo(2000, 6); + expect(tracker.velocity(200).x).toBeCloseTo(2000, 6); + expect(tracker.velocity(201)).toEqual({x: 0, y: 0}); + expect(tracker.scrollVelocity(201)).toEqual({x: 0, y: 0}); + }); + + it('releases at 0 with fewer than two moved samples (AST-100)', () => { + const tracker = new VelocityTracker(); + tracker.add(0, 0, 0); + tracker.add(10, 0, 8); + // One moved sample: the live value is that interval, the release is 0. + expect(tracker.velocity().x).toBeCloseTo(1250, 6); + expect(tracker.velocity(10)).toEqual({x: 0, y: 0}); + expect(tracker.scrollVelocity(10)).toEqual({x: 0, y: 0}); + tracker.add(20, 0, 16); + expect(tracker.velocity(18).x).toBeCloseTo(1250, 6); + }); + + it('updates only on moved samples (INV10)', () => { + const tracker = new VelocityTracker(); + tracker.add(0, 0, 0); + tracker.add(10, 0, 10); + tracker.add(20, 0, 20); + const moving = tracker.velocity(); + // An event at the same position is not motion: nothing changes, and the + // next move's interval runs from the last moved sample. + tracker.add(20, 0, 30); + expect(tracker.velocity()).toEqual(moving); + tracker.add(30, 0, 40); + // 10 px over the 20 ms since the last move: 500 px/s, after 1000 px/s. + expect(tracker.velocity().x).toBeCloseTo(0.2 * 500 + 0.8 * 1000, 6); + }); + + it('blends a scrolling surface\u2019s release from the last two pan values (INV10)', () => { + const tracker = new VelocityTracker(); + tracker.add(0, 0, 0); + tracker.add(10, 0, 10); + tracker.add(20, 0, 20); + const previous = tracker.velocity().x; + tracker.add(40, 0, 30); + const newest = tracker.velocity().x; + expect(tracker.scrollVelocity(32).x).toBeCloseTo( + 0.25 * newest + 0.75 * previous, + 6, + ); + }); + + // Four UIPickerView swipes, HID touches on the iOS 26.2 simulator: each + // stream is [ms since touch-down, y in pt], and the release is the velocity + // UIKit handed scrollViewWillEndDragging, in offset px/s. + it.each([ + [ + '200 pt in 50 ms', + [ + [0, 368], + [46.48, 334.67], + [72.92, 201.33], + ], + 933.4, + ], + [ + '200 pt in 100 ms', + [ + [0, 368], + [49.2, 351.33], + [65.27, 301.33], + [88.47, 251.33], + [104.68, 218], + [129.4, 168], + ], + 2114, + ], + [ + '200 pt in 200 ms', + [ + [0, 368], + [46.01, 359.67], + [63.5, 334.67], + [81.36, 318], + [97.69, 301.33], + [120.12, 276.33], + [137.2, 259.67], + [171.83, 226.33], + [196.13, 201.33], + [212.66, 184.67], + [230.83, 168], + ], + 1016.2, + ], + [ + 'a 90 pt flick', + [ + [0, 353], + [41.5, 335], + [62.02, 281], + ], + 543.6, + ], + ] as const)( + "reproduces UIKit's scroll release on a recorded UIPickerView swipe, %s (INV10)", + (_, stream, release) => { + const tracker = new VelocityTracker(); + for (const [time, y] of stream) { + tracker.add(0, y, time); + } + const lift = stream[stream.length - 1][0]; + // The content moves against the finger: its velocity is the finger's, + // negated. + const offset = -tracker.scrollVelocity(lift).y; + expect(Math.abs(offset - release) / release).toBeLessThan(0.001); + }, + ); + + it('reads a steady 1000 px/s exactly', () => { + const tracker = new VelocityTracker(); + trace(tracker, t => t, 200); + expect(tracker.velocity().x).toBeCloseTo(1000, 6); + expect(tracker.scrollVelocity(200).x).toBeCloseTo(1000, 6); + }); + + it('lags a hard acceleration by a frame, as UIKit does', () => { + // x = 0.02 t^2 (px, ms). UIKit's filter is 0.8 on the previous interval + // (its middle, 188 ms: 7520 px/s) and 0.2 on the newest (196 ms: + // 7840 px/s). + const tracker = new VelocityTracker(); + trace(tracker, t => 0.02 * t * t, 200); + expect(tracker.velocity().x).toBeCloseTo(0.8 * 7520 + 0.2 * 7840, 3); + }); + + it('handles one and two samples, and repeated timestamps', () => { + const tracker = new VelocityTracker(); + expect(tracker.velocity()).toEqual({x: 0, y: 0}); + tracker.add(0, 0, 0); + expect(tracker.velocity()).toEqual({x: 0, y: 0}); + tracker.add(10, 20, 10); + // One interval so far: reported whole. + expect(tracker.velocity()).toEqual({x: 1000, y: 2000}); + // The same timestamp replaces the newest sample. + tracker.add(20, 20, 10); + expect(tracker.velocity().x).toBeCloseTo(2000, 6); + // An older timestamp is ignored. + tracker.add(500, 500, 5); + expect(tracker.lastSample).toEqual({x: 20, y: 20, time: 10}); + }); + + it('forgets everything on reset', () => { + const tracker = new VelocityTracker(); + trace(tracker, t => t, 80); + tracker.reset(); + expect(tracker.lastSample).toBeNull(); + expect(tracker.velocity()).toEqual({x: 0, y: 0}); + expect(tracker.velocity(80)).toEqual({x: 0, y: 0}); + }); +}); diff --git a/packages/core/src/gestures/__tests__/arenaContinuous.test.ts b/packages/core/src/gestures/__tests__/arenaContinuous.test.ts new file mode 100644 index 0000000000000..22a3e627453e7 --- /dev/null +++ b/packages/core/src/gestures/__tests__/arenaContinuous.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file arenaContinuous.test.ts + * @input GestureArena with pans, long presses, taps, consumers and scroll + * probes, driven by synthetic touches with explicit timestamps + * @output The pan's conflicts: native scrolling (failing, scroll edge, + * handoff), long press vs pan, nested pans, delegate hooks, two fingers on + * two views, delivery while moving, cancellation from code, the clock + * @position Unit tests of packages/core/src/gestures/GestureArena.ts + */ + +import {describe, expect, it} from 'vitest'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness, RecordingConsumer, scrollProbe} from './harness'; + +describe('conflict matrix', () => { + it('pan vs native scroll: the browser taking the touch fails a possible pan', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan', direction: 'vertical'}); + h.finger(1) + .down(0, 100, 0, {recognizers: [pan]}) + .move(0, 105, 16); + h.finger(1).takenByBrowser(24); + // The touch is gone from the pan, so it is ready for the next one. + expect(h.log).toEqual(['pan:failed@24', 'pan:possible@24']); + expect(h.claims).toEqual([]); + }); + + it('pan at a scroll edge (scrollEdge): begins and claims when the content cannot scroll that way', () => { + const atTop = createHarness(); + const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, + }); + // Content at its top: a finger moving down scrolls nothing. + const probe = scrollProbe((_dx, dy) => dy < 0); + atTop.finger(1).down(0, 100, 0, {recognizers: [sheet], scroll: probe}); + atTop.finger(1).drag(0, 30, 50, 5); + expect(atTop.log[0]).toBe('sheet:began@20'); + expect(atTop.claims).toEqual([1]); + + const midContent = createHarness(); + const scrolled = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, + }); + const canScrollUp = scrollProbe(() => true); + midContent.finger(1).down(0, 100, 0, { + recognizers: [scrolled], + scroll: canScrollUp, + }); + midContent.finger(1).drag(0, 30, 50, 5); + expect(midContent.log).toEqual(['sheet:failed@20']); + }); + + it("a drag that began as a scroll stays the browser's past the content's edge (spec:AST-094 FR14)", () => { + const h = createHarness(); + const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, + }); + let scrollTop = 30; + const finger = h.finger(1).down(0, 100, 0, { + recognizers: [sheet], + scroll: scrollProbe(() => scrollTop > 0), + }); + // The content scrolls toward its top: the drag is the browser's. + finger.move(0, 120, 16); + finger.takenByBrowser(20); + // It reaches its top mid-gesture; the finger keeps pulling. + scrollTop = 0; + finger.move(0, 160, 32); + finger.move(0, 200, 48); + finger.up(64); + // It failed at its first move and is reset when the browser takes the + // touch: it never begins, however far the finger pulls. + expect(h.log).toEqual(['sheet:failed@16', 'sheet:possible@20']); + }); + + it('long press vs pan: holding still wins for the long press', () => { + const h = createHarness(); + const press = new LongPressGestureRecognizer({name: 'press'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(0, 0, 0, {recognizers: [press, pan]}); + h.advance(500); + expect(h.log).toEqual(['press:began@500', 'pan:failed@500']); + h.finger(1).move(80, 0, 600); + expect(press.state).toBe('changed'); + expect(pan.state).toBe('failed'); + expect(h.claims).toEqual([1]); + }); + + it('long press vs pan: moving first wins for the pan', () => { + const h = createHarness(); + const press = new LongPressGestureRecognizer({name: 'press'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(0, 0, 0, {recognizers: [press, pan]}); + h.finger(1).move(5, 0, 100).move(12, 0, 120); + expect(h.log).toEqual(['press:failed@120', 'pan:began@120']); + h.advance(600); + expect(press.state).toBe('failed'); + }); + + it('nested pans that both qualify on the same move: the innermost wins', () => { + const h = createHarness(); + const inner = new PanGestureRecognizer({name: 'inner'}); + const outer = new PanGestureRecognizer({name: 'outer'}); + h.finger(1) + .down(0, 0, 0, {recognizers: [inner, outer]}) + .move(20, 0, 16); + expect(h.log).toEqual(['inner:began@16', 'outer:failed@16']); + }); + + it('honors shouldBegin and shouldReceiveTouch', () => { + const h = createHarness(); + const refused = new PanGestureRecognizer({ + name: 'refused', + shouldBegin: () => false, + }); + const blind = new TapGestureRecognizer({ + name: 'blind', + shouldReceiveTouch: () => false, + }); + h.finger(1) + .down(0, 0, 0, {recognizers: [refused, blind]}) + .move(30, 0, 16); + expect(h.log).toEqual(['refused:failed@16']); + expect(blind.numberOfTouches).toBe(0); + }); + + it('two fingers on two unrelated views drive two pans at once', () => { + const h = createHarness(); + const left = new PanGestureRecognizer({name: 'left'}); + const right = new PanGestureRecognizer({name: 'right'}); + h.finger(1).down(50, 300, 0, {recognizers: [left]}); + h.finger(2).down(300, 300, 0, {recognizers: [right]}); + h.finger(1).move(50, 340, 16); + h.finger(2).move(300, 260, 16); + expect(left.state).toBe('began'); + expect(right.state).toBe('began'); + }); +}); + +describe('delivery to the view', () => { + it('delivers an unclaimed touch to the consumer and allows its click', () => { + const h = createHarness(); + const consumer = new RecordingConsumer(); + const pan = new PanGestureRecognizer({name: 'pan'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [pan], consumer}); + finger.move(3, 0, 16); + expect(finger.up(60)).toBe('allow'); + expect(consumer.events).toEqual(['began:1', 'moved:1', 'ended:1']); + }); + + it('cancels the view and swallows the click when a recognizer takes the touch', () => { + const h = createHarness(); + const consumer = new RecordingConsumer(); + const pan = new PanGestureRecognizer({name: 'pan'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [pan], consumer}); + finger.move(30, 0, 16).move(40, 0, 32); + expect(consumer.events).toEqual(['began:1', 'cancelled:1']); + expect(finger.up(60)).toBe('suppress'); + }); + + it('keeps the view and the click with cancelsTouchesInView off', () => { + const h = createHarness(); + const consumer = new RecordingConsumer(); + const pan = new PanGestureRecognizer({ + name: 'pan', + cancelsTouchesInView: false, + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [pan], consumer}); + finger.move(30, 0, 16); + expect(finger.up(60)).toBe('allow'); + expect(consumer.events).toEqual(['began:1', 'moved:1', 'ended:1']); + }); +}); + +describe('cancellation', () => { + it('stop() cancels an active recognizer from code', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1) + .down(0, 0, 0, {recognizers: [pan]}) + .move(30, 0, 16); + pan.cancel(); + expect(pan.state).toBe('cancelled'); + h.finger(1).move(60, 0, 32); + expect(pan.state).toBe('cancelled'); + h.finger(1).up(48); + expect(pan.state).toBe('possible'); + }); + + it('never moves the event clock backwards', () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(0, 0, 100, {recognizers: [pan]}); + h.finger(1).move(30, 0, 90); + expect(h.log).toEqual(['pan:began@100']); + }); +}); diff --git a/packages/core/src/gestures/types.ts b/packages/core/src/gestures/types.ts index 91fc86377a785..09d0dd0eca38f 100644 --- a/packages/core/src/gestures/types.ts +++ b/packages/core/src/gestures/types.ts @@ -49,8 +49,8 @@ export interface NativeScrollProbe { /** * How much further (px) the scroller that would move for a finger moving * by (dx, dy) can scroll before it reaches its edge; 0 when none would. - * Lets a scroll handoff place the point where the content ran out between - * two samples instead of at the next one. + * Lets a reader place the point where the content runs out between two + * samples instead of at the next one. */ distanceToEdge?(dx: number, dy: number): number; /** Whether any element under the touch can scroll natively at all. */ diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index 3030484136ba4..96b5578d38fb0 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -78,24 +78,26 @@ Thresholds the gesture system reads (`uikit/gestures.ts`). Safari on iOS runs UIKit's own recognizers for tap-to-click, long press, double tap and the start of a page scroll, so many of these can be read off Safari itself. -| ID | Feeds | Capture | Method | -| ----- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| M-G01 | `UIKIT_GESTURES.tap.allowableMovement` | Safari (UIKit tap -> click): on a touch-action:none box, HID touch down, move N px over 100 ms, lift; binary-search the largest N that still fires click. | C | -| M-G02 | `UIKIT_GESTURES.tap.maximumIntervalBetweenTaps` | Safari with user-scalable=no (UIKit double-tap -> dblclick): two HID taps T ms apart; binary-search the largest T that fires dblclick. | C | -| M-G03 | `UIKIT_GESTURES.tap.allowableSeparation` | Safari with user-scalable=no: two HID taps 150 ms apart, the second D px away; binary-search the largest D that fires dblclick. | C | -| M-G04 | `UIKIT_GESTURES.tap.maximumTapDuration` | Safari: HID hold on a