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:
+227
-25
@@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API.
|
||||
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
|
||||
- [Type reference](#type-reference)
|
||||
- [Errors and constraints](#errors-and-constraints)
|
||||
- [Gestures in real apps](#gestures-in-real-apps)
|
||||
|
||||
## Gesture start recognition
|
||||
|
||||
@@ -43,6 +44,7 @@ gesture.from.top(distance);
|
||||
gesture.from.bottom(distance);
|
||||
gesture.from.anywhere();
|
||||
gesture.from.when(predicate);
|
||||
gesture.from.scrollEdge(scroller, edge);
|
||||
```
|
||||
|
||||
`distance` accepts a number in CSS pixels or a CSS length string, including
|
||||
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
|
||||
.animate(dropAnimation);
|
||||
```
|
||||
|
||||
Interactive form controls are ignored automatically. Add
|
||||
`.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll
|
||||
container is scrolled all the way to `edge`. It is the drag-down-to-dismiss
|
||||
rule for sheets and pushed pages: at the top, dragging down dismisses and
|
||||
dragging up still scrolls; anywhere else the list just scrolls. `scroller` is
|
||||
an element, a ref or getter returning one, or a CSS selector resolved inside
|
||||
the gesture host. A scroller that is not mounted counts as at the edge.
|
||||
|
||||
```ts
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const dismiss = gesture.from
|
||||
.scrollEdge(list, "top")
|
||||
.to.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(sheetDown);
|
||||
```
|
||||
|
||||
Start rules also come as composable predicates for `.from.when()`:
|
||||
|
||||
| Helper | Admits pointer-down |
|
||||
| ------------------------------ | ------------------------------------------- |
|
||||
| `startedIn(selector)` | On or inside an element matching `selector` |
|
||||
| `nearEdge(edge, distance)` | Within `distance` of one side of the host |
|
||||
| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` |
|
||||
| `anyOf(...predicates)` | When any predicate does |
|
||||
| `allOf(...predicates)` | When every predicate does |
|
||||
|
||||
```ts
|
||||
gesture.from
|
||||
.when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
|
||||
.to.down();
|
||||
```
|
||||
|
||||
Interactive form controls (`input`, `textarea`, `select`, `option`, and
|
||||
`[contenteditable="true"]`) are ignored automatically. Add
|
||||
`data-origin-gesture="ignore"` to any other element or ancestor that should not
|
||||
begin a gesture.
|
||||
begin a gesture, such as a horizontally scrolling chip row inside a host with a
|
||||
horizontal gesture.
|
||||
|
||||
## Minimum setup
|
||||
|
||||
@@ -184,15 +220,13 @@ 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 with `useOriginGesture()`, forwards every pointer event to every
|
||||
binding, and derives the least-permissive shared `touch-action`:
|
||||
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).
|
||||
|
||||
- horizontal only: `pan-y`;
|
||||
- vertical only: `pan-x`;
|
||||
- both axes: `none`.
|
||||
|
||||
The `gestures` list is reactive, including its length and the derived
|
||||
`touch-action`. Recognizers are kept per list position, so a re-render with new
|
||||
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
|
||||
from the next pointer-down. The owning page remains the visible declaration
|
||||
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
||||
@@ -427,6 +461,16 @@ change a definition already produced from it.
|
||||
| --------------- | ------- | ------------------------------------------------- |
|
||||
| `threshold` | `8` | Directed CSS pixels required before capture |
|
||||
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
|
||||
| `distance` | host | Movement that counts as full progress |
|
||||
|
||||
`distance` is resolved at pointer-down: a number in pixels, a CSS length
|
||||
(percentages are of the host), or a function `({ host, bounds }) => pixels`.
|
||||
Set it when the moving element is smaller than the host, such as a drawer 84%
|
||||
of the screen wide, so the drawer stays under the finger:
|
||||
|
||||
```ts
|
||||
gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 });
|
||||
```
|
||||
|
||||
If `.complete()` is omitted, the choreography's `commitThreshold` and
|
||||
`commitVelocity` decide release normally.
|
||||
@@ -452,10 +496,30 @@ const openProfile = gesture.to
|
||||
.animate(slideLeft);
|
||||
```
|
||||
|
||||
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
|
||||
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
|
||||
`onCommit` rather than `then` so builder stages never become promise-like
|
||||
"thenables" when awaited or returned from an async function.
|
||||
Handlers run inside the `pointerup` event whenever the operation has already
|
||||
begun, which is the usual case, so APIs that need the user's gesture (iOS
|
||||
haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
|
||||
|
||||
The method is named `onCommit` rather than `then` so builder stages never
|
||||
become promise-like "thenables" when awaited or returned from an async
|
||||
function.
|
||||
|
||||
`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during
|
||||
the drag: `onArm` when releasing would start to commit, `onDisarm` when it no
|
||||
longer would. Use them for a "release now" cue or haptic. Arming follows
|
||||
distance (`progress >= commitThreshold`) because velocity changes too quickly to
|
||||
drive a cue without flickering; when the definition has a `.complete()`
|
||||
predicate, that predicate decides arming instead.
|
||||
|
||||
```ts
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Archive)))
|
||||
.onArm(() => haptic("light"))
|
||||
.onDisarm(() => haptic("soft"))
|
||||
.onCommit(() => haptic("medium"))
|
||||
.animate(slideLeft);
|
||||
```
|
||||
|
||||
### `useOriginGesture(definition)`
|
||||
|
||||
@@ -482,22 +546,32 @@ interface OriginGestureBinding {
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
readonly onPointercancel: () => void;
|
||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||
readonly onTouchmove: (event: TouchEvent) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Apply all handlers to the same element. The returned style sets dimensions and
|
||||
`touch-action` so native scrolling remains available on the cross-axis.
|
||||
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`.
|
||||
|
||||
Recognition requires:
|
||||
|
||||
1. A primary, left-button pointer satisfies the optional start policy.
|
||||
2. The target is not an ignored interactive element.
|
||||
3. Directed movement reaches `threshold`.
|
||||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||
5. The navigation factory returns an intent.
|
||||
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.
|
||||
|
||||
Progress is directed distance divided by host width or height. Release velocity
|
||||
is normalized by the same dimension.
|
||||
Progress is directed distance divided by the gesture's `distance` (by default
|
||||
the host's width or height). Past either end, the excess reaches
|
||||
choreographies as `overshoot`. Release velocity is normalized by the same
|
||||
distance and measured as the trend of the last 100ms of samples, so uneven
|
||||
touch timing does not distort it and a pointer held still before release has
|
||||
no flick.
|
||||
|
||||
An asynchronous navigation factory is supported. If it resolves after the
|
||||
pointer was released or cancelled, the stale result is discarded.
|
||||
@@ -585,10 +659,12 @@ can run repeatedly during rendering and animation.
|
||||
When `finish()` does not explicitly override the decision, the release velocity
|
||||
decides first, then distance:
|
||||
|
||||
- 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`.
|
||||
- a flick at least `commitVelocity` fast (default `0.2` progress units per
|
||||
second, about 80px/s across a 390px phone) decides by its direction: forward
|
||||
commits, backward cancels, regardless of progress;
|
||||
- a slower release commits when `progress >= commitThreshold`, default `0.25`.
|
||||
|
||||
The included presets use these defaults.
|
||||
|
||||
Velocity is normalized by the gesture host's size, so `1` is one full width (or
|
||||
height) per second.
|
||||
@@ -778,6 +854,21 @@ Node-local action context:
|
||||
| `originRect` | Origin bounds captured before target mounting |
|
||||
| `targetRect` | Target bounds measured after mounting |
|
||||
| `viewport` | Scene-container bounds, with browser viewport fallback |
|
||||
| `overshoot` | Drag distance past either end of `progress`, else `0` |
|
||||
|
||||
`overshoot` is negative when a drag is pulled back beyond the start and
|
||||
positive beyond full progress. Use it for rubber-banding:
|
||||
|
||||
```ts
|
||||
effects: ({ progress, overshoot }) => ({
|
||||
target: {
|
||||
transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The settling spring starts from the overshot position, so a rubber-banded drag
|
||||
springs back continuously.
|
||||
|
||||
Rect values are viewport CSS pixels.
|
||||
|
||||
@@ -831,3 +922,114 @@ cause:
|
||||
- A choreography creates one target. Chaining supports any number of
|
||||
simultaneously mounted targets.
|
||||
- Reduced-motion preference resolves settling immediately.
|
||||
|
||||
## Gestures in real apps
|
||||
|
||||
### Scrolling
|
||||
|
||||
Gestures and native scrolling share the same touches. Each recognizer decides
|
||||
within the first 6px of movement whether a touch is its own:
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
Nested scrollers need nothing special. Their `touchmove` events bubble to the
|
||||
gesture host, which cancels them only while it holds the touch.
|
||||
|
||||
### Ignored elements
|
||||
|
||||
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.
|
||||
|
||||
### Animation performance
|
||||
|
||||
Animate `transform` and `opacity` only. Browsers composite them without
|
||||
layout or paint. Animating `filter`, `clip-path`, `box-shadow`, or
|
||||
`border-radius` through `effect.style` repaints every frame and is the usual
|
||||
cause of janky transitions; if you need one of them, set it once at rest
|
||||
rather than per frame.
|
||||
|
||||
### Continuity between choreographies
|
||||
|
||||
In development builds the scene warns when an operation's first frame moves a
|
||||
node that was visibly at rest. This catches the one-frame "pop" when a close
|
||||
choreography's progress-0 effects do not match the pose the previous
|
||||
choreography left behind, typically a `persistAtRest` drawer or sheet whose
|
||||
open (at 1) and close (at 0) effects disagree. The check runs only in
|
||||
development (`process.env.NODE_ENV !== "production"`, which bundlers replace)
|
||||
and only in browsers, since it reads rendered styles.
|
||||
|
||||
### Haptics
|
||||
|
||||
`onArm`, `onDisarm`, and `onCommit` run inside the pointer event, which is
|
||||
where platforms allow feedback:
|
||||
|
||||
- **Android, Chrome**: `navigator.vibrate(10)`.
|
||||
- **iOS Safari**: there is no Vibration API. Haptics are only reachable
|
||||
through a native wrapper, or through an undocumented technique: toggling a
|
||||
hidden `<input type="checkbox" switch>`, which iOS 18+ Safari renders as a
|
||||
switch with a system haptic. It only produces a haptic during a user gesture,
|
||||
so call it from these handlers, never after an `await`, and expect it to
|
||||
change between iOS releases.
|
||||
- **Capacitor**: `Haptics.impact()` from `@capacitor/haptics`.
|
||||
|
||||
### Operating-system edge gestures
|
||||
|
||||
The screen edges belong to the operating system first. On iOS, a swipe from the
|
||||
left edge goes back in Safari's history, including in apps installed to the
|
||||
home screen (`display: standalone`), which have no back button. On Android
|
||||
with gesture navigation, a swipe from either side edge is the system back
|
||||
gesture. Pages cannot claim those touches, so `.from.left()` or `.from.right()`
|
||||
with a narrow zone competes with them. Prefer a zone that starts a little
|
||||
inside the edge, or a wider one (`.from.when(nearEdge("left", 56))`), and give
|
||||
every back gesture an on-screen alternative.
|
||||
|
||||
Web apps cannot disable the operating system's gesture, but an installed
|
||||
progressive web app can make it do the right thing, so it backs out of the
|
||||
scene instead of leaving the app:
|
||||
|
||||
- **Android (Chrome 120+)**: listen for close requests with `CloseWatcher`.
|
||||
The system back gesture and back button send one; route it to the scene's
|
||||
back operation. A watcher must be created during a user activation (a tap
|
||||
or a gesture), and after it fires it cannot fire again until the user
|
||||
interacts again, so recreate it after each navigation:
|
||||
|
||||
```ts
|
||||
let watcher: CloseWatcher | undefined;
|
||||
function watchSystemBack(origin: UseOrigin) {
|
||||
watcher?.destroy();
|
||||
if (!("CloseWatcher" in window) || !origin.canGoBack.value) return;
|
||||
watcher = new CloseWatcher();
|
||||
watcher.onclose = () => origin.perform(back(slideRight));
|
||||
}
|
||||
// Call from a gesture's .onCommit() or a click handler, after navigating.
|
||||
```
|
||||
|
||||
Without `CloseWatcher`, push a history entry when the scene navigates
|
||||
forward (`history.pushState({ scene: true }, "")`) and handle `popstate` by
|
||||
performing the scene's back. The system gesture then pops that entry instead
|
||||
of leaving the app.
|
||||
|
||||
- **iOS (standalone)**: the edge swipe navigates browser history. Scenes never
|
||||
add history entries, so in an installed app with no history to go back to
|
||||
the swipe should have nothing to do. If the app also uses the History API,
|
||||
map `popstate` to the scene's back as above.
|
||||
- **Browser history swipes** (two-finger trackpad swipes, and horizontal
|
||||
overscroll navigation in Chromium): `overscroll-behavior-x: none` on the page's
|
||||
scroll container stops horizontal overscroll from triggering navigation.
|
||||
|
||||
Verify the behaviour on real devices: these platform gestures are not part of
|
||||
any web standard and change between releases.
|
||||
|
||||
Reference in New Issue
Block a user