diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index 1bfed0e..e799a89 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -582,13 +582,16 @@ can run repeatedly during rendering and animation. ### Commit thresholds -When `finish()` does not explicitly override the decision, a target commits -when either: +When `finish()` does not explicitly override the decision, the release velocity +decides first, then distance: -- `progress >= commitThreshold`, default `0.36`; or -- release `velocity >= commitVelocity`, default `0.3` progress units per - second, at any progress. Velocity is normalized by the gesture host's size, - so `1` is one full width (or height) per second. +- a flick at least `commitVelocity` fast (default `0.3` progress units per + second) decides by its direction: forward commits, backward cancels, + regardless of progress; +- a slower release commits when `progress >= commitThreshold`, default `0.36`. + +Velocity is normalized by the gesture host's size, so `1` is one full width (or +height) per second. The operation's intent becomes final before its spring settles. diff --git a/packages/core-v2/src/gesture/recognizer.ts b/packages/core-v2/src/gesture/recognizer.ts index bc51b18..0458a9b 100644 --- a/packages/core-v2/src/gesture/recognizer.ts +++ b/packages/core-v2/src/gesture/recognizer.ts @@ -303,6 +303,11 @@ interface PointerSession { captured: boolean; /** The scene operation, once navigation resolved and `begin()` was called. */ operation: Promise | null; + /** + * The operation's handle once `begin()` resolved, so pointer-up can finish + * it synchronously, inside the event, without awaiting the promise. + */ + handle: OriginOperationHandle | null; } /** @@ -368,6 +373,7 @@ export class GestureRecognizer { ), captured: false, operation: null, + handle: null, }; } @@ -388,14 +394,17 @@ export class GestureRecognizer { this.session = null; return; } - session.operation = this.origin.begin(action).catch((error) => { - // Another recognizer on this origin won the race; that is expected. - if (!( - error instanceof OriginSceneError && error.code === "origin-busy" - )) - warn("A gesture could not begin its operation.", error); - return null; - }); + session.operation = this.origin.begin(action).then( + (handle) => (session.handle = handle), + (error) => { + // Another recognizer on this origin won the race; that is expected. + if (!( + error instanceof OriginSceneError && error.code === "origin-busy" + )) + warn("A gesture could not begin its operation.", error); + return null; + }, + ); } else { event.preventDefault(); session.tracker.sample(event); @@ -433,7 +442,11 @@ export class GestureRecognizer { const commit = definition.completion?.(context); const { progress, velocity } = tracker; - const handle = session.operation ? await session.operation : null; + // Usually the operation began long ago: finish it without awaiting, so + // commit handlers run inside this pointer-up (iOS only allows haptics, + // audio, and clipboard writes during the user's gesture). + const handle = + session.handle ?? (session.operation ? await session.operation : null); if (!handle) return; handle.update(progress, velocity); // finish() fixes the outcome synchronously, before the spring settles. @@ -445,9 +458,16 @@ export class GestureRecognizer { await settled; } - /** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */ + /** + * The host lost pointer capture without a pointer-up; cancel like + * pointer-cancel. The event bubbles, and taking capture makes the touched + * child lose its implicit touch capture, so only the host's own loss counts. + */ pointerCaptureLost(event: PointerEvent): Promise { - return this.sessionFor(event) ? this.abandon() : Promise.resolve(); + const session = this.sessionFor(event); + return session && event.target === session.host + ? this.abandon() + : Promise.resolve(); } pointerCancel(): Promise { diff --git a/packages/core-v2/src/scene/graph.ts b/packages/core-v2/src/scene/graph.ts index 2f05714..bd4496e 100644 --- a/packages/core-v2/src/scene/graph.ts +++ b/packages/core-v2/src/scene/graph.ts @@ -73,17 +73,22 @@ export const DEFAULT_COMMIT_THRESHOLD = 0.36; */ export const DEFAULT_COMMIT_VELOCITY = 0.3; -/** Decide a release outcome from explicit options or progress/velocity. */ +/** + * Decide a release outcome from explicit options, or from velocity and + * progress: a flick at least `commitVelocity` fast decides by its direction, + * forward committing and backward cancelling, regardless of distance. Slower + * releases commit once progress reaches `commitThreshold`. + */ export const shouldCommit = ( operation: MutableOriginOperation, options: OriginFinishOptions, ): boolean => { if (options.commit !== undefined) return options.commit; const { commitThreshold, commitVelocity } = operation.choreography; - return ( - operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) || - operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY) - ); + const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY; + if (operation.velocity >= flick) return true; + if (operation.velocity <= -flick) return false; + return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD); }; /** diff --git a/packages/core-v2/tests/haptics.test.ts b/packages/core-v2/tests/haptics.test.ts index 38d53e6..6020782 100644 --- a/packages/core-v2/tests/haptics.test.ts +++ b/packages/core-v2/tests/haptics.test.ts @@ -247,6 +247,32 @@ describe(".onCommit()", () => { } }); + it("runs synchronously inside the pointer-up event", async () => { + let calledDuringDispatch = false; + let dispatching = false; + const { host } = await mount( + gesture.to + .left() + .navigate(() => forward(originView(Target))) + .onCommit(() => { + calledDuringDispatch = dispatching; + }) + .animate(motion), + ); + host.dispatchEvent(pointer("pointerdown", 190, 0)); + host.dispatchEvent(pointer("pointermove", 180, 500)); + await flush(); + host.dispatchEvent(pointer("pointermove", 40, 1000)); + await flush(); + + // No awaiting between the event and the handler: iOS only allows + // haptics, audio, and clipboard writes during the user's gesture. + dispatching = true; + host.dispatchEvent(pointer("pointerup", 40, 1000)); + dispatching = false; + expect(calledDuringDispatch).toBe(true); + }); + it("fires at release, before the settling animation finishes", async () => { const frames: FrameRequestCallback[] = []; vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => diff --git a/packages/core-v2/tests/recognizer.test.ts b/packages/core-v2/tests/recognizer.test.ts index bfcb677..fa85dd5 100644 --- a/packages/core-v2/tests/recognizer.test.ts +++ b/packages/core-v2/tests/recognizer.test.ts @@ -108,6 +108,26 @@ describe("stuck gestures", () => { expect(scene.nodes.value).toHaveLength(1); }); + it("ignores a child losing implicit touch capture to the host", async () => { + const Root = defineComponent({ + setup: () => () => + h(OriginGesture, { id: "host", gesture: definition }, () => + h("span", { id: "child" }, "x"), + ), + }); + const { scene, host } = await mount(Root); + await dragLeft(host); + expect(scene.operations.value).toHaveLength(1); + + // Taking capture on the host makes the touched child lose its implicit + // capture; that event bubbles to the host. + host + .querySelector("#child")! + .dispatchEvent(pointer("lostpointercapture", 120, 110)); + await flush(); + expect(scene.operations.value).toHaveLength(1); + }); + it("ignores lostpointercapture after a normal release", async () => { const { scene, host } = await mount(gestureHost(() => definition)); await dragLeft(host); @@ -318,6 +338,22 @@ describe("commitVelocity", () => { expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true); }); + it("lets a fast flick's direction veto the distance", () => { + const at = (progress: number, velocity: number) => + ({ + progress, + velocity, + choreography: { effects: () => ({}) }, + }) as unknown as Parameters[0]; + // Past the 0.36 threshold, but flicked back toward the start: cancels. + expect(shouldCommit(at(0.8, -0.5), {})).toBe(false); + // Barely started, but flicked forward: commits. + expect(shouldCommit(at(0.05, 0.5), {})).toBe(true); + // Slow releases fall back to distance, in either direction. + expect(shouldCommit(at(0.8, -0.1), {})).toBe(true); + expect(shouldCommit(at(0.2, 0.1), {})).toBe(false); + }); + it("still yields to an explicit decision", () => { expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false); });