Restore native scrolling and reliable recognition; add feel tests
The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
+70
-32
@@ -220,11 +220,10 @@ const gestures = [forwardGesture, backGesture] as const;
|
||||
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||||
|
||||
The component adds no recognition or navigation policy. It installs each
|
||||
definition, forwards every pointer and touch event to every recognizer, and
|
||||
lets the recognizers share the touch: each locks onto a touch moving along its
|
||||
own axis and direction and lets go of any other, so a horizontal drawer, a
|
||||
pull-up sheet, and vertical scrolling can live on one host. See
|
||||
[Scrolling](#scrolling).
|
||||
definition, forwards every pointer event to every recognizer, and derives one
|
||||
`touch-action` that leaves native scrolling on every pan no gesture needs. A
|
||||
horizontal drawer and a scroll-edge pull-down can share a host with vertical
|
||||
scrolling. See [Scrolling](#scrolling).
|
||||
|
||||
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
|
||||
definition objects never interrupts a gesture in progress; each change applies
|
||||
@@ -546,25 +545,43 @@ interface OriginGestureBinding {
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
readonly onPointercancel: () => void;
|
||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||
readonly onTouchmove: (event: TouchEvent) => void;
|
||||
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
|
||||
readonly ref: (element: unknown) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Apply all handlers to the same element, as non-passive listeners (Vue's
|
||||
`@touchmove` is), and apply the returned style. The style sets dimensions and
|
||||
`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out
|
||||
of a gesture the recognizer has claimed. Without it, the browser takes over
|
||||
vertical and horizontal drags with `pointercancel`.
|
||||
Apply the pointer handlers, the returned style, and `listeners` to the same
|
||||
element, and pass `ref` as its function ref:
|
||||
|
||||
```vue
|
||||
<div
|
||||
:ref="binding.ref"
|
||||
:style="binding.style"
|
||||
v-bind="binding.listeners"
|
||||
@pointerdown="binding.onPointerdown"
|
||||
@pointermove="binding.onPointermove"
|
||||
@pointerup="binding.onPointerup"
|
||||
@pointercancel="binding.onPointercancel"
|
||||
@lostpointercapture="binding.onLostpointercapture"
|
||||
/>
|
||||
```
|
||||
|
||||
The style sets dimensions and the host's `touch-action` (see
|
||||
[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for
|
||||
scroll-edge gestures it tracks the scroller, and where the browser lacks
|
||||
directional `touch-action` it adds a `touchmove` handler.
|
||||
|
||||
Recognition requires:
|
||||
|
||||
1. A primary, left-button pointer satisfies the optional start policy.
|
||||
2. The target is not an ignored interactive element.
|
||||
3. The first 6px of movement are along the gesture's axis and direction (see
|
||||
[Scrolling](#scrolling)).
|
||||
4. Directed movement reaches `threshold`.
|
||||
5. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||
6. The navigation factory returns an intent.
|
||||
3. Directed movement from the pointer-down point reaches `threshold`.
|
||||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||
5. The navigation factory returns an intent.
|
||||
|
||||
Recognition is measured from where the pointer went down and keeps watching
|
||||
until the pointer lifts, so a slightly wobbly or backward start only delays
|
||||
it.
|
||||
|
||||
Progress is directed distance divided by the gesture's `distance` (by default
|
||||
the host's width or height). Past either end, the excess reaches
|
||||
@@ -927,24 +944,43 @@ cause:
|
||||
|
||||
### Scrolling
|
||||
|
||||
Gestures and native scrolling share the same touches. Each recognizer decides
|
||||
within the first 6px of movement whether a touch is its own:
|
||||
Native scrolling stays in the browser's hands: gesture hosts never make it
|
||||
wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
|
||||
gesture needs:
|
||||
|
||||
- **along its axis and direction**: it holds the touch, and the binding's
|
||||
`onTouchmove` cancels native scrolling while it does;
|
||||
- **along the other axis, or the opposite way**: it lets go, and the browser
|
||||
scrolls (or another gesture on the host takes it);
|
||||
- **diagonal**: it keeps watching until the movement is clear.
|
||||
| Gestures on the host | `touch-action` |
|
||||
| --------------------------------------------------------------- | ---------------- |
|
||||
| Horizontal only | `pan-y` |
|
||||
| Vertical only | `pan-x` |
|
||||
| Both axes | `none` |
|
||||
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
|
||||
| The same, list scrolled | `manipulation` |
|
||||
|
||||
Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the
|
||||
decision is made before native scrolling can start. This is why gesture hosts
|
||||
use `touch-action: manipulation`: the browser may pan either axis, and the
|
||||
recognizers keep it from doing so only for touches they claim. A vertical
|
||||
dismiss gesture therefore no longer disables vertical scrolling; combine it
|
||||
with `.from.scrollEdge()` so it only claims a downward drag at the top.
|
||||
The browser therefore scrolls the free axis natively from the first frame,
|
||||
and touches on a gesture's axis reach the recognizer untouched.
|
||||
|
||||
Nested scrollers need nothing special. Their `touchmove` events bubble to the
|
||||
gesture host, which cancels them only while it holds the touch.
|
||||
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
|
||||
above a list, uses `.from.scrollEdge()`. While the list is at that edge, the
|
||||
host reserves only the one pan the gesture needs: at the top, dragging down is
|
||||
the gesture's and dragging up still scrolls the list natively. This is the
|
||||
pull-to-refresh pattern from the Pointer Events specification, and it relies
|
||||
on direction-specific `touch-action` values (`pan-down` and its siblings). The
|
||||
host follows the list's scroll position through a passive scroll listener, so
|
||||
the right value is in place before each touch starts.
|
||||
|
||||
Browsers without direction-specific values (Safari) fall back to deciding in
|
||||
JavaScript: the host keeps the axis pannable and cancels native scrolling
|
||||
from a `touchmove` handler for touches the gesture claims, at the browser's
|
||||
first cancelable `touchmove` (after roughly 15–20px of movement). Blocking
|
||||
touch handlers make the browser wait for JavaScript on every move, so this
|
||||
fallback is used only for scroll-edge gestures and only where it is needed.
|
||||
|
||||
Nested scroll containers: browsers apply `touch-action` only up to the nearest
|
||||
scroll container, so a list scrolling inside a horizontal gesture host would
|
||||
otherwise let the browser take horizontal drags and cancel the gesture. Each
|
||||
host publishes its value as the inherited `--nvo-touch-action` custom property,
|
||||
and the package stylesheet applies it to descendants, so nested lists keep
|
||||
`pan-y` too. This requires `@native-vue-router/core-v2/style.css`.
|
||||
|
||||
### Ignored elements
|
||||
|
||||
@@ -952,7 +988,9 @@ Pointer-down on `input`, `textarea`, `select`, `option`, or
|
||||
`[contenteditable="true"]` never starts a gesture, so text selection and
|
||||
form controls keep working. Mark anything else that must keep its own drags
|
||||
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
|
||||
row or a slider inside a host that has a horizontal gesture.
|
||||
row or a slider inside a host that has a horizontal gesture. Ignored subtrees
|
||||
also get `touch-action: auto` back from the package stylesheet, so a chip row
|
||||
scrolls horizontally inside a horizontal gesture host.
|
||||
|
||||
### Animation performance
|
||||
|
||||
|
||||
Reference in New Issue
Block a user