Fix touch gestures cancelling, backward flicks, and late onCommit
- Only the host's own lostpointercapture cancels a gesture. Taking capture makes the touched child lose its implicit touch capture, and that bubbling event was cancelling every touch gesture immediately. - A flick at least commitVelocity fast now decides by its direction: backward cancels even past the distance threshold, forward commits. Slower releases still decide by distance. - Pointer-up finishes an already-begun operation without awaiting, so onCommit runs inside the pointer-up event, where iOS still allows haptics, audio, and clipboard writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -303,6 +303,11 @@ interface PointerSession {
|
||||
captured: boolean;
|
||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | 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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<typeof shouldCommit>[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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user