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
|
### Commit thresholds
|
||||||
|
|
||||||
When `finish()` does not explicitly override the decision, a target commits
|
When `finish()` does not explicitly override the decision, the release velocity
|
||||||
when either:
|
decides first, then distance:
|
||||||
|
|
||||||
- `progress >= commitThreshold`, default `0.36`; or
|
- a flick at least `commitVelocity` fast (default `0.3` progress units per
|
||||||
- release `velocity >= commitVelocity`, default `0.3` progress units per
|
second) decides by its direction: forward commits, backward cancels,
|
||||||
second, at any progress. Velocity is normalized by the gesture host's size,
|
regardless of progress;
|
||||||
so `1` is one full width (or height) per second.
|
- 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.
|
The operation's intent becomes final before its spring settles.
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ interface PointerSession {
|
|||||||
captured: boolean;
|
captured: boolean;
|
||||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||||
operation: Promise<OriginOperationHandle | null> | null;
|
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,
|
captured: false,
|
||||||
operation: null,
|
operation: null,
|
||||||
|
handle: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,14 +394,17 @@ export class GestureRecognizer {
|
|||||||
this.session = null;
|
this.session = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.operation = this.origin.begin(action).catch((error) => {
|
session.operation = this.origin.begin(action).then(
|
||||||
|
(handle) => (session.handle = handle),
|
||||||
|
(error) => {
|
||||||
// Another recognizer on this origin won the race; that is expected.
|
// Another recognizer on this origin won the race; that is expected.
|
||||||
if (!(
|
if (!(
|
||||||
error instanceof OriginSceneError && error.code === "origin-busy"
|
error instanceof OriginSceneError && error.code === "origin-busy"
|
||||||
))
|
))
|
||||||
warn("A gesture could not begin its operation.", error);
|
warn("A gesture could not begin its operation.", error);
|
||||||
return null;
|
return null;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
session.tracker.sample(event);
|
session.tracker.sample(event);
|
||||||
@@ -433,7 +442,11 @@ export class GestureRecognizer {
|
|||||||
const commit = definition.completion?.(context);
|
const commit = definition.completion?.(context);
|
||||||
const { progress, velocity } = tracker;
|
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;
|
if (!handle) return;
|
||||||
handle.update(progress, velocity);
|
handle.update(progress, velocity);
|
||||||
// finish() fixes the outcome synchronously, before the spring settles.
|
// finish() fixes the outcome synchronously, before the spring settles.
|
||||||
@@ -445,9 +458,16 @@ export class GestureRecognizer {
|
|||||||
await settled;
|
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> {
|
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> {
|
pointerCancel(): Promise<void> {
|
||||||
|
|||||||
@@ -73,17 +73,22 @@ export const DEFAULT_COMMIT_THRESHOLD = 0.36;
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_COMMIT_VELOCITY = 0.3;
|
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 = (
|
export const shouldCommit = (
|
||||||
operation: MutableOriginOperation,
|
operation: MutableOriginOperation,
|
||||||
options: OriginFinishOptions,
|
options: OriginFinishOptions,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (options.commit !== undefined) return options.commit;
|
if (options.commit !== undefined) return options.commit;
|
||||||
const { commitThreshold, commitVelocity } = operation.choreography;
|
const { commitThreshold, commitVelocity } = operation.choreography;
|
||||||
return (
|
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
|
||||||
operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) ||
|
if (operation.velocity >= flick) return true;
|
||||||
operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY)
|
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 () => {
|
it("fires at release, before the settling animation finishes", async () => {
|
||||||
const frames: FrameRequestCallback[] = [];
|
const frames: FrameRequestCallback[] = [];
|
||||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||||
|
|||||||
@@ -108,6 +108,26 @@ describe("stuck gestures", () => {
|
|||||||
expect(scene.nodes.value).toHaveLength(1);
|
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 () => {
|
it("ignores lostpointercapture after a normal release", async () => {
|
||||||
const { scene, host } = await mount(gestureHost(() => definition));
|
const { scene, host } = await mount(gestureHost(() => definition));
|
||||||
await dragLeft(host);
|
await dragLeft(host);
|
||||||
@@ -318,6 +338,22 @@ describe("commitVelocity", () => {
|
|||||||
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
|
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", () => {
|
it("still yields to an explicit decision", () => {
|
||||||
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user