Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2: Scrolling (#3, #4, #14) - Each recognizer locks onto a touch within its first 6px: along its axis and direction it holds the touch, otherwise it lets go. A new onTouchmove binding cancels native scrolling only while a recognizer holds the touch, so hosts use touch-action: manipulation and one vertical gesture no longer disables vertical scrolling. Several gestures on one host share touches through the same rule. - .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a scroller is at the edge (#5). Feel - recognition.distance sets what counts as full progress: pixels, a CSS length, or a measuring function (#6). - Choreographies receive overshoot, the drag past either end of the clamped progress, and the settling spring starts from it (#7). - Velocity is the least-squares trend of the last 100ms (#8). - Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the slide presets use them (#9). - Choreographies may set spring stiffness and damping (#10). Hooks and helpers - .onArm()/.onDisarm() run during a drag when releasing would start or stop committing (#12). - startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15). - Development builds warn when an operation's first frame moves a node that was visibly at rest, the one-frame "pop" of mismatched open and close choreographies (#16). Docs: scrolling, ignored elements, animation performance, haptics, and operating-system edge gestures including CloseWatcher/popstate for PWAs (#11, #17, #18). Verification: a Chromium harness with emulated touch (verify/browser) checks swipes over scrollers, native scrolling beside gestures, scroll-edge dismissal, onCommit inside pointerup, and the continuity warning. Reverting the earlier capture fix makes it fail exactly as reported ("none of the handles work"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.row {
|
||||
height: 80px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createApp, defineComponent, h, ref } from "vue";
|
||||
import {
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
useOrigin,
|
||||
} from "../../../src";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
log: string[];
|
||||
inPointerup: boolean;
|
||||
scene: ReturnType<typeof createOriginScene>;
|
||||
scrollTopOf: (selector: string) => number;
|
||||
begin: (name: "matched" | "mismatched") => Promise<void>;
|
||||
}
|
||||
}
|
||||
window.log = [];
|
||||
window.inPointerup = false;
|
||||
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
|
||||
window.addEventListener("pointerup", () =>
|
||||
setTimeout(() => (window.inPointerup = false)),
|
||||
);
|
||||
const warn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
window.log.push("warn: " + String(args[0]));
|
||||
warn(...args);
|
||||
};
|
||||
|
||||
const rows = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
|
||||
|
||||
const Detail = defineComponent({
|
||||
name: "Detail",
|
||||
setup() {
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const dismiss = gesture.from
|
||||
.scrollEdge(list, "top")
|
||||
.to.down()
|
||||
.navigate((c) => (c.canGoBack ? back() : null))
|
||||
.onCommit(() => window.log.push("dismiss committed"))
|
||||
.animate(
|
||||
defineOriginChoreography({
|
||||
name: "dismiss",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateY(${progress * 100}%)` },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return () =>
|
||||
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
|
||||
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const Home = defineComponent({
|
||||
name: "Home",
|
||||
setup() {
|
||||
const open = gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
|
||||
.onCommit(() =>
|
||||
window.log.push(`open committed inPointerup=${window.inPointerup}`),
|
||||
)
|
||||
.animate(slideLeft);
|
||||
return () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ gestures: [open], class: "page", id: "home" },
|
||||
() => h("div", { class: "list", id: "home-list" }, rows(40)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Continuity check pair: a drawer that rests at 80% and a close that starts
|
||||
// either from the same pose (matched) or from 0 (mismatched).
|
||||
const drawerOpen = defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 80}%)` },
|
||||
}),
|
||||
});
|
||||
const closes = {
|
||||
matched: defineOriginChoreography({
|
||||
name: "drawer-close-matched",
|
||||
effects: ({ progress }) => ({
|
||||
target: { transform: `translateX(${(1 - progress) * 80}%)` },
|
||||
}),
|
||||
}),
|
||||
mismatched: defineOriginChoreography({
|
||||
name: "drawer-close-mismatched",
|
||||
effects: ({ progress }) => ({
|
||||
target: { transform: `translateX(${(1 - progress) * 50}%)` },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const Drawer = defineComponent({
|
||||
name: "Drawer",
|
||||
render: () => h("div", { class: "page" }, "drawer"),
|
||||
});
|
||||
const Plain = defineComponent({
|
||||
name: "Plain",
|
||||
setup() {
|
||||
const origin = useOrigin();
|
||||
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
|
||||
() =>
|
||||
origin.perform(
|
||||
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
|
||||
);
|
||||
return () => h("div", { class: "page", id: "plain" }, "plain");
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const initial = params.get("view") === "plain" ? Plain : Home;
|
||||
const scene = createOriginScene({
|
||||
initial: originView(initial, undefined, { key: "root" }),
|
||||
});
|
||||
window.scene = scene;
|
||||
window.scrollTopOf = (selector) =>
|
||||
document.querySelector(selector)?.scrollTop ?? -1;
|
||||
window.begin = async (name) => {
|
||||
await (
|
||||
window as unknown as { openDrawer: () => Promise<boolean> }
|
||||
).openDrawer();
|
||||
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
|
||||
const handle = await scene.begin(drawerKey, back(closes[name]));
|
||||
await handle.cancel({ animate: false });
|
||||
await handle.finish({ commit: false, animate: false });
|
||||
};
|
||||
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
|
||||
@@ -0,0 +1,8 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
build: { outDir: "dist", emptyOutDir: true, minify: false },
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dirname, "app/dist");
|
||||
const types = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
};
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = join(
|
||||
root,
|
||||
new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"),
|
||||
);
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(path);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
return res.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": types[extname(path)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
}).listen(0);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
|
||||
// CHROMIUM_PATH overrides Playwright's bundled Chromium.
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROMIUM_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
const results = [];
|
||||
const check = (name, ok, detail) => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
};
|
||||
|
||||
async function open(query = "") {
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => console.log("pageerror", e.message));
|
||||
await page.goto(base + query);
|
||||
await page.waitForFunction(() => window.scene);
|
||||
return { page, cdp: await context.newCDPSession(page) };
|
||||
}
|
||||
async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: from[0], y: from[1] }],
|
||||
});
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = from[0] + ((to[0] - from[0]) * i) / steps;
|
||||
const y = from[1] + ((to[1] - from[1]) * i) / steps;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x, y }],
|
||||
});
|
||||
await page.waitForTimeout(stepMs);
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
const states = (page) =>
|
||||
page.evaluate(() =>
|
||||
window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "),
|
||||
);
|
||||
const log = (page) => page.evaluate(() => window.log.slice());
|
||||
|
||||
// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel).
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
const s = await states(t.page);
|
||||
check(
|
||||
"horizontal swipe over a scroller opens Detail",
|
||||
s === "root:parked detail:active",
|
||||
s,
|
||||
);
|
||||
const l = await log(t.page);
|
||||
check(
|
||||
"onCommit runs inside the real pointerup event",
|
||||
l.includes("open committed inPointerup=true"),
|
||||
l.join(" | "),
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [200, 700], [205, 300]);
|
||||
const s = await states(t.page);
|
||||
const top = await t.page.evaluate(() => window.scrollTopOf("#home-list"));
|
||||
check(
|
||||
"vertical drag scrolls the list natively",
|
||||
top > 100,
|
||||
`scrollTop=${top}`,
|
||||
);
|
||||
check(
|
||||
"vertical drag does not start the horizontal gesture",
|
||||
s === "root:active",
|
||||
s,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 3. Scroll-edge dismiss.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
check(
|
||||
"(setup) Detail open",
|
||||
(await states(t.page)) === "root:parked detail:active",
|
||||
);
|
||||
await drag(t, [200, 700], [200, 300]); // scroll the list down
|
||||
const scrolled = await t.page.evaluate(() =>
|
||||
window.scrollTopOf("#detail-list"),
|
||||
);
|
||||
check(
|
||||
"drag up scrolls the detail list",
|
||||
scrolled > 100,
|
||||
`scrollTop=${scrolled}`,
|
||||
);
|
||||
await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss
|
||||
const afterDown = await t.page.evaluate(() =>
|
||||
window.scrollTopOf("#detail-list"),
|
||||
);
|
||||
check(
|
||||
"drag down while scrolled scrolls the list, not dismiss",
|
||||
afterDown < scrolled &&
|
||||
(await states(t.page)) === "root:parked detail:active",
|
||||
`scrollTop ${scrolled} → ${afterDown}`,
|
||||
);
|
||||
await t.page.evaluate(
|
||||
() => (document.querySelector("#detail-list").scrollTop = 0),
|
||||
);
|
||||
await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses
|
||||
const s = await states(t.page);
|
||||
const l = await log(t.page);
|
||||
check(
|
||||
"drag down at the top dismisses",
|
||||
s === "root:active" && l.includes("dismiss committed"),
|
||||
s,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 3b. At the top, an upward drag still scrolls the list.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
await drag(t, [200, 700], [200, 300]);
|
||||
const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list"));
|
||||
check(
|
||||
"at the top, drag up still scrolls (dismiss lets go)",
|
||||
top > 100 && (await states(t.page)) === "root:parked detail:active",
|
||||
`scrollTop=${top}`,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 4. Continuity warning.
|
||||
for (const [name, expectWarning] of [
|
||||
["matched", false],
|
||||
["mismatched", true],
|
||||
]) {
|
||||
const t = await open("?view=plain");
|
||||
await t.page.evaluate((n) => window.begin(n), name);
|
||||
await t.page.waitForTimeout(100);
|
||||
const warned = (await log(t.page)).some((l) => l.includes("moved node"));
|
||||
check(
|
||||
`continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`,
|
||||
warned === expectWarning,
|
||||
(await log(t.page)).join(" | "),
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
console.log(`\n${results.length - failed}/${results.length} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user