feat(stream-header): move pin/unpin gesture onto dedicated 32px drag-handle with 1:1 finger tracking and desktop-style grabber animation
This commit is contained in:
parent
7c5a1f2ee7
commit
e866cd3830
5 changed files with 443 additions and 58 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
CURTAIN_RADIUS_PX,
|
||||
CURTAIN_SNAP_EASING,
|
||||
CURTAIN_SNAP_MS,
|
||||
HANDLE_HEIGHT_PX,
|
||||
TABS_ROW_PX,
|
||||
} from './geometry';
|
||||
|
||||
|
|
@ -121,6 +122,64 @@ export const curtain = style({
|
|||
willChange: 'top',
|
||||
});
|
||||
|
||||
// Drag handle at the top of the curtain. Dedicated touch surface for
|
||||
// the pin / unpin gesture so it doesn't compete with the chat list's
|
||||
// vertical scroll. `touchAction: none` keeps the browser from claiming
|
||||
// the gesture for native scroll heuristics — our `touchmove` listener
|
||||
// in `useCurtainHandleGesture` drives every pixel of motion.
|
||||
//
|
||||
// Sits as the first flex child of the curtain so the list (or
|
||||
// DirectEmpty / equivalent placeholder) takes the remaining space
|
||||
// below it. `flexShrink: 0` locks the height so a long list doesn't
|
||||
// squash the hit-zone.
|
||||
export const handle = style({
|
||||
flexShrink: 0,
|
||||
height: toRem(HANDLE_HEIGHT_PX),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'none',
|
||||
});
|
||||
|
||||
// Visual «grabber» pill centred inside `handle`. Semi-transparent
|
||||
// foreground so the affordance reads as «draggable» without competing
|
||||
// with content beneath. Pure decoration — the parent `handle` div
|
||||
// captures the touch.
|
||||
//
|
||||
// State machine mirrors `PageNavResizeHandle` on desktop: a subtle
|
||||
// resting state, a more prominent «being dragged» state, and an even
|
||||
// more prominent «threshold reached, release to commit» state. The
|
||||
// state-driving `data-dragging` / `data-at-commit` attributes live on
|
||||
// the parent `handle` div (set by StreamHeader.tsx from the gesture
|
||||
// hook). Transition durations match the desktop handle (140ms ease)
|
||||
// so the two affordances feel related.
|
||||
export const handleBar = style({
|
||||
width: toRem(40),
|
||||
height: toRem(4),
|
||||
borderRadius: toRem(2),
|
||||
backgroundColor: color.Background.OnContainer,
|
||||
opacity: 0.25,
|
||||
pointerEvents: 'none',
|
||||
transition:
|
||||
'opacity 140ms ease, width 140ms ease, height 140ms ease, background-color 140ms ease',
|
||||
selectors: {
|
||||
// Dragging but threshold not yet reached: highlight, slight grow.
|
||||
'[data-dragging="true"] &': {
|
||||
opacity: 0.55,
|
||||
width: toRem(48),
|
||||
backgroundColor: color.Primary.Main,
|
||||
},
|
||||
// Threshold reached during drag: full stretch + opacity. Releasing
|
||||
// here commits pin (or unpin). Reads as «yes, you're there».
|
||||
'[data-dragging="true"][data-at-commit="true"] &': {
|
||||
opacity: 0.9,
|
||||
width: toRem(64),
|
||||
height: toRem(5),
|
||||
backgroundColor: color.Primary.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wrapper around `bottomPinned` inside the curtain. Anchored to the
|
||||
// curtain's flex-bottom by virtue of being the last child. The TSX
|
||||
// applies a `transform: translateY(keyboardH)` to this element when
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import React, {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -23,6 +24,7 @@ import { Segment } from './Segment';
|
|||
import { Chip } from './Chip';
|
||||
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
||||
import { useCurtainGesture } from './useCurtainGesture';
|
||||
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
|
||||
import { InlineNewChatForm } from './forms/InlineNewChatForm';
|
||||
import { InlineRoomSearch } from './forms/InlineRoomSearch';
|
||||
|
||||
|
|
@ -130,6 +132,35 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
disabled: gestureDisabled,
|
||||
});
|
||||
|
||||
// Dedicated pin / unpin gesture on the drag handle at the top of
|
||||
// the curtain. The handle exists specifically because the list-bound
|
||||
// pin path conflicts with vertical list scroll at `scrollTop === 0`
|
||||
// — see `useCurtainHandleGesture` and `useCurtainGesture`'s list-
|
||||
// scroll guard for the full rationale. Same `disabled` gating as the
|
||||
// list gesture so a bottom sheet or offscreen pager pane can't
|
||||
// accidentally pin/unpin.
|
||||
//
|
||||
// `handleVisual` mirrors the desktop `PageNavResizeHandle` state
|
||||
// machine: `dragging` lights up the grabber pill while the gesture
|
||||
// is active; `atCommit` further stretches + brightens it once the
|
||||
// user has crossed `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` so the
|
||||
// release-to-confirm moment is unambiguous. The hook dedupes so this
|
||||
// only re-renders when the visual state actually changes.
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
|
||||
dragging: false,
|
||||
atCommit: false,
|
||||
});
|
||||
useCurtainHandleGesture({
|
||||
handleRef,
|
||||
snap: curtain.snap,
|
||||
pinned: curtain.pinned,
|
||||
setPinned: curtain.setPinned,
|
||||
setLiveDrag: curtain.setLiveDrag,
|
||||
disabled: gestureDisabled,
|
||||
setHandleState: setHandleVisual,
|
||||
});
|
||||
|
||||
const isActive = isFormSnap(curtain.snap);
|
||||
const openSearch = useCallback(() => curtain.open('search'), [curtain]);
|
||||
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
|
||||
|
|
@ -396,6 +427,29 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
}}
|
||||
onTransitionEnd={onCurtainTransitionEnd}
|
||||
>
|
||||
{/* Drag handle (native-only behaviour, but rendered on all
|
||||
platforms so the layout stays identical — the gesture hook
|
||||
short-circuits off-native). Hosts the pin / unpin touch
|
||||
listener and gives the user a visible affordance at the top
|
||||
of the curtain. Stays mounted across snap transitions so
|
||||
the gesture surface is always reachable when there is one
|
||||
to make.
|
||||
|
||||
`data-dragging` / `data-at-commit` mirror the desktop
|
||||
`PageNavResizeHandle`: CSS selectors on `handleBar` light
|
||||
the pill up Primary-blue + stretch it when these flip.
|
||||
Both attrs are emitted/cleared only via React state set by
|
||||
the gesture hook (dedup'd), so the handle visual updates
|
||||
without slamming the DOM on every touchmove. */}
|
||||
<div
|
||||
ref={handleRef}
|
||||
className={css.handle}
|
||||
data-dragging={handleVisual.dragging || undefined}
|
||||
data-at-commit={handleVisual.atCommit || undefined}
|
||||
aria-hidden
|
||||
>
|
||||
<div className={css.handleBar} />
|
||||
</div>
|
||||
{children}
|
||||
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
|
||||
kept mounted across snaps so the curtain reads as a self-
|
||||
|
|
|
|||
|
|
@ -92,28 +92,32 @@ export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
|
|||
// the stage).
|
||||
export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
||||
|
||||
// Rubber-band attenuation for the pin / unpin drag. Finger → curtain
|
||||
// motion is scaled by this factor so the curtain feels heavier than
|
||||
// the finger — a deliberate gate against accidental drag-up.
|
||||
//
|
||||
// User-confirmed intent: «вверх дотянуть нужно явно» — pinning must be
|
||||
// an obvious sustained pull, not a casual flick. With 0.45 the user
|
||||
// must drag ~142px of finger to slide the curtain across the full
|
||||
// PIN_TRAVEL_PX (64px); combined with the very-high commit threshold
|
||||
// below the minimum committing pull is ~135px, which is well above
|
||||
// any plausible accidental scroll-attempt at scrollTop=0.
|
||||
//
|
||||
// Same factor applies to unpin (drag down from pinned) so both
|
||||
// directions feel consistent — neither commits on a casual gesture.
|
||||
export const PIN_RUBBER_BAND = 0.45;
|
||||
|
||||
// Commit threshold for pin / unpin. Tuned very high (≈95%) so the
|
||||
// user must drag the curtain almost-all-the-way to the cap before
|
||||
// release for the snap to flip. Anything shorter reads as accidental
|
||||
// and springs back to the previous resting snap.
|
||||
//
|
||||
// The geometric meaning: after rubber-band, lastDelta ranges 0 …
|
||||
// ±PIN_TRAVEL_PX (clamped). The commit fires when |lastDelta| ≥
|
||||
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, i.e. the curtain visually
|
||||
// reached at least 95% of the snap distance.
|
||||
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin
|
||||
// — see `useCurtainGesture` and `useCurtainHandleGesture`), the
|
||||
// committing finger pull is `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈
|
||||
// 61 px — essentially «drag the curtain across the full tabs-row
|
||||
// height». The anti-accidental gate that previously came from
|
||||
// rubber-band amplification is now provided by the dedicated handle
|
||||
// hit-zone (intentional surface) plus the list-bound scroll-aware
|
||||
// bail (no list scroll = no scroll-up to confuse with pin).
|
||||
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||
|
||||
// Drag-handle hit-zone at the top of the curtain. Hosts the pin /
|
||||
// unpin gesture as a dedicated touch surface so it doesn't compete
|
||||
// with the chat list's vertical scroll. Drag on this handle tracks
|
||||
// the finger 1:1 (no rubber-band) — finger displacement equals
|
||||
// curtain displacement. The list-bound gesture in
|
||||
// `useCurtainGesture` still owns peek / form-close, plus the pin path
|
||||
// when the list has no scrollable content (so single-screen lists
|
||||
// keep the «drag-from-anywhere» pin behaviour).
|
||||
//
|
||||
// Size: 32 px tall — enough touch target to land on comfortably with
|
||||
// a thumb (the visible grabber pill inside is much smaller, see
|
||||
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
|
||||
// equivalent placeholder) starts 32 px below the curtain's top edge.
|
||||
export const HANDLE_HEIGHT_PX = 32;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
DIRECTION_DEAD_ZONE_PX,
|
||||
PEEK_TRAVEL_PX,
|
||||
PIN_COMMIT_THRESHOLD,
|
||||
PIN_RUBBER_BAND,
|
||||
PIN_TRAVEL_PX,
|
||||
RUBBER_BAND,
|
||||
} from './geometry';
|
||||
|
|
@ -58,11 +57,16 @@ type Args = {
|
|||
// with a hard clamp at -PIN_TRAVEL_PX (curtain stops flush above the
|
||||
// tabs row — never enters the system-tray safe-top zone). On release
|
||||
// past `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` flips `pinned = true`.
|
||||
// Only reachable when the list has no scrollable content — with scroll
|
||||
// the user's drag-up means «scroll list down» and the gesture defers
|
||||
// to native (see the scroll-aware bail in `onTouchMove`).
|
||||
//
|
||||
// Unpin path: drag DOWN while `pinned = true` tracks 1:1 clamped at
|
||||
// +PIN_TRAVEL_PX. On release past the same threshold flips
|
||||
// `pinned = false`. The user-confirmed UX choice is single-stop —
|
||||
// unpin lands at `closed` only; peek requires a separate gesture.
|
||||
// Unpin: NOT handled here. Once pinned, the list-bound gesture is
|
||||
// inert in both directions — `useCurtainHandleGesture` (the handle at
|
||||
// the top of the curtain) owns the unpin transition exclusively. A
|
||||
// drag-down on the list while pinned used to unpin here, but that
|
||||
// path was a confusing back-door: the handle is the canonical
|
||||
// pin/unpin surface and the only one the user is taught.
|
||||
//
|
||||
// Form-close path: drag UP from a form snap tracks the finger 1:1; on
|
||||
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
|
||||
|
|
@ -160,26 +164,51 @@ export function useCurtainGesture({
|
|||
direction = delta > 0 ? 'down' : 'up';
|
||||
|
||||
// Direction guards:
|
||||
// - pinned ⇒ only DOWN (unpin); UP would try to push past
|
||||
// y = -safe-top into the system-tray zone, which we never
|
||||
// want.
|
||||
// - pinned ⇒ list-bound gesture is inert. UP would push past
|
||||
// y = -safe-top into the system-tray zone; DOWN would
|
||||
// unpin, but unpin is handle-only territory (the dedicated
|
||||
// drag-handle at the top of the curtain — see
|
||||
// `useCurtainHandleGesture`). Both bail here.
|
||||
// - peek ⇒ only UP (close); DOWN has nowhere lower to go.
|
||||
// - form ⇒ only UP (close); DOWN reads as a list scroll.
|
||||
// - closed + UP ⇒ pin path (new — used to bail).
|
||||
// - closed + DOWN ⇒ peek path (existing).
|
||||
if (currentPinned && direction === 'up') {
|
||||
// - closed + UP ⇒ pin path (only when list has no scroll).
|
||||
// - closed + DOWN ⇒ peek path.
|
||||
if (currentPinned) {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (!currentPinned && currentSnap === 'peek' && direction === 'down') {
|
||||
// Below this point currentPinned is provably false (the pinned
|
||||
// bail above returned). The `!currentPinned` qualifier on each
|
||||
// remaining guard would be redundant, so it's dropped.
|
||||
if (currentSnap === 'peek' && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (!currentPinned && isFormSnap(currentSnap) && direction === 'down') {
|
||||
if (isFormSnap(currentSnap) && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
// Pin from the LIST is allowed only when the list has nothing
|
||||
// to scroll (single-screen chat list). With scrollable content,
|
||||
// drag-up at `scrollTop === 0` is the user's natural «scroll
|
||||
// list down to reveal more rows» gesture and hijacking it for
|
||||
// pin (and the resulting preventDefault) leaves the user
|
||||
// staring at a frozen list. Pinning a scrollable list goes
|
||||
// through the dedicated handle at the top of the curtain
|
||||
// instead (see `useCurtainHandleGesture`). Single-screen lists
|
||||
// keep the drag-from-anywhere pin path so the original UX is
|
||||
// preserved when there's no list-scroll to compete with.
|
||||
if (
|
||||
currentSnap === 'closed' &&
|
||||
direction === 'up' &&
|
||||
list.scrollHeight > list.clientHeight + 1
|
||||
) {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
|
|
@ -190,27 +219,24 @@ export function useCurtainGesture({
|
|||
engaged = true;
|
||||
e.preventDefault();
|
||||
|
||||
if (currentPinned) {
|
||||
// Unpin: finger moves DOWN (delta > 0). Rubber-banded by
|
||||
// PIN_RUBBER_BAND so the curtain feels heavy — same factor as
|
||||
// the pin path below for symmetry. Clamped at +PIN_TRAVEL_PX
|
||||
// so the curtain doesn't visually descend past its `closed`
|
||||
// resting top during the drag.
|
||||
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * PIN_RUBBER_BAND));
|
||||
} else if (isFormSnap(currentSnap)) {
|
||||
// currentPinned is false here (the pinned bail above returned).
|
||||
// List-bound pin/unpin doesn't exist as a path on this hook —
|
||||
// unpin is exclusively the handle hook's responsibility, and
|
||||
// pin from the list is only reachable in the no-scroll branch
|
||||
// below.
|
||||
if (isFormSnap(currentSnap)) {
|
||||
// Form close: finger moves UP (delta < 0). Track 1:1, capped
|
||||
// at 0 so an accidental downward jitter doesn't push the
|
||||
// curtain below its resting position.
|
||||
lastDelta = Math.min(0, delta);
|
||||
} else if (currentSnap === 'closed' && direction === 'up') {
|
||||
// Pin: finger moves UP (delta < 0). Rubber-banded by
|
||||
// PIN_RUBBER_BAND — the curtain moves at ≈45% of finger speed
|
||||
// so the user has to commit a deliberate sustained pull to
|
||||
// close the gap. Clamped at -PIN_TRAVEL_PX so it never enters
|
||||
// the system-tray safe-top zone. See geometry's «pinned visual
|
||||
// contract» and `PIN_RUBBER_BAND` rationale for the full
|
||||
// invariant.
|
||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * PIN_RUBBER_BAND));
|
||||
// Pin from list (only reachable when the list has no scroll
|
||||
// — see the scroll-aware bail above). 1:1 finger ↔ curtain
|
||||
// so the user gets the same direct feel as the dedicated
|
||||
// handle. Clamped at -PIN_TRAVEL_PX so the curtain never
|
||||
// enters the system-tray safe-top zone (see geometry's
|
||||
// «pinned visual contract»).
|
||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
|
||||
} else {
|
||||
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
|
||||
// more chips; up (delta < 0) retreats toward `closed`. Bounds
|
||||
|
|
@ -233,16 +259,10 @@ export function useCurtainGesture({
|
|||
let next: CurtainSnap = currentSnap;
|
||||
let nextPinned = currentPinned;
|
||||
|
||||
if (currentPinned) {
|
||||
// Unpin commit: drag DOWN past ≥ PIN_COMMIT_THRESHOLD of the
|
||||
// full travel flips pinned → false. The user-confirmed UX is
|
||||
// single-stop — unpin lands at `closed`; peek requires a
|
||||
// separate gesture.
|
||||
const progress = lastDelta / PIN_TRAVEL_PX;
|
||||
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||||
nextPinned = false;
|
||||
}
|
||||
} else if (isFormSnap(currentSnap)) {
|
||||
// currentPinned is false here in any engaged gesture (the
|
||||
// direction guard bails when pinned). The unpin commit branch
|
||||
// that used to live here moved to `useCurtainHandleGesture`.
|
||||
if (isFormSnap(currentSnap)) {
|
||||
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
|
||||
next = 'closed';
|
||||
}
|
||||
|
|
|
|||
248
src/app/components/stream-header/useCurtainHandleGesture.ts
Normal file
248
src/app/components/stream-header/useCurtainHandleGesture.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { DIRECTION_DEAD_ZONE_PX, PIN_COMMIT_THRESHOLD, PIN_TRAVEL_PX } from './geometry';
|
||||
import { CurtainSnap } from './useCurtainState';
|
||||
|
||||
type Args = {
|
||||
// Drag-handle element at the top of the curtain. Touch events bind
|
||||
// here; the chat list's scroll viewport is untouched so native
|
||||
// vertical scroll never races the pin gesture. Mounted as the first
|
||||
// flex child of the curtain in StreamHeader.tsx.
|
||||
handleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
// Current snap stop. Mirrored into a ref so the listener (bound
|
||||
// once per `disabled` flip) reads fresh values without re-attaching.
|
||||
// Only `closed` (→ pin) and the pinned overlay (→ unpin) engage.
|
||||
snap: CurtainSnap;
|
||||
// Per-pane pinned overlay. When true the handle's drag-down path
|
||||
// commits unpin; when false (and `snap === 'closed'`) drag-up
|
||||
// commits pin.
|
||||
pinned: boolean;
|
||||
// Setter for the pinned overlay; called on release once the user's
|
||||
// drag past the commit threshold qualifies the gesture.
|
||||
setPinned: (next: boolean) => void;
|
||||
// Setter for the live drag delta during touchmove. The hook reads
|
||||
// `liveDragPx` from the parent state too, so React drives the
|
||||
// curtain's `top` re-render — no direct DOM writes.
|
||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||
// Suppress gesture binding entirely. Used to gate pinning when a
|
||||
// bottom sheet is open or when this pane is inactive inside the
|
||||
// swipe pager. Mirrors `useCurtainGesture.disabled`.
|
||||
disabled?: boolean;
|
||||
// Optional sink for handle-visual state — used by StreamHeader to
|
||||
// drive the grabber pill's «idle / dragging / threshold reached»
|
||||
// appearance via `data-dragging` and `data-at-commit` attributes
|
||||
// on the handle div. Called only when the state actually changes,
|
||||
// so the consumer doesn't pay a re-render on every touchmove.
|
||||
setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
|
||||
};
|
||||
|
||||
// Touch-gesture driver for the curtain's pin / unpin path on the
|
||||
// dedicated handle strip. Native-only: listeners aren't attached on
|
||||
// web / desktop.
|
||||
//
|
||||
// Why a separate hook? The list-bound `useCurtainGesture` had its pin
|
||||
// path on the chat list's scroll viewport, where drag-up at
|
||||
// `scrollTop === 0` collided with the user's natural «scroll list
|
||||
// down» gesture (preventDefault on touchmove blocked native scroll for
|
||||
// any pin attempt). Moving pin / unpin onto a small handle above the
|
||||
// list resolves the conflict — the list-bound hook now lets native
|
||||
// scroll handle vertical drags on the list, and the handle owns the
|
||||
// pin transitions.
|
||||
//
|
||||
// Pin path: from `closed` (and not pinned), drag UP tracks the finger
|
||||
// 1:1 (no rubber-band) clamped at -PIN_TRAVEL_PX. On release past
|
||||
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` (≈ 61 px finger pull) flips
|
||||
// `pinned = true`.
|
||||
//
|
||||
// Unpin path: from pinned, drag DOWN tracks 1:1 clamped at
|
||||
// +PIN_TRAVEL_PX. Same commit threshold.
|
||||
//
|
||||
// Other snap states (peek, form-*) are no-ops here — the existing
|
||||
// list-bound gesture continues to own those transitions.
|
||||
export function useCurtainHandleGesture({
|
||||
handleRef,
|
||||
snap,
|
||||
pinned,
|
||||
setPinned,
|
||||
setLiveDrag,
|
||||
disabled,
|
||||
setHandleState,
|
||||
}: Args): void {
|
||||
const snapRef = useRef<CurtainSnap>(snap);
|
||||
snapRef.current = snap;
|
||||
const pinnedRef = useRef<boolean>(pinned);
|
||||
pinnedRef.current = pinned;
|
||||
const setPinnedRef = useRef(setPinned);
|
||||
setPinnedRef.current = setPinned;
|
||||
const setHandleStateRef = useRef(setHandleState);
|
||||
setHandleStateRef.current = setHandleState;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNativePlatform()) return undefined;
|
||||
if (disabled) return undefined;
|
||||
const handle = handleRef.current;
|
||||
if (!handle) return undefined;
|
||||
|
||||
let startX: number | null = null;
|
||||
let startY: number | null = null;
|
||||
let direction: 'up' | 'down' | null = null;
|
||||
let engaged = false;
|
||||
let lastDelta = 0;
|
||||
// Last visual state emitted to the consumer. We dedupe here so the
|
||||
// setter (likely a React useState) only re-renders when something
|
||||
// actually changed, not on every 60fps touchmove.
|
||||
let emittedDragging = false;
|
||||
let emittedAtCommit = false;
|
||||
const emitHandle = (dragging: boolean, atCommit: boolean) => {
|
||||
if (dragging === emittedDragging && atCommit === emittedAtCommit) return;
|
||||
emittedDragging = dragging;
|
||||
emittedAtCommit = atCommit;
|
||||
setHandleStateRef.current?.({ dragging, atCommit });
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
// Engage only when the gesture has somewhere to go:
|
||||
// - not pinned + closed ⇒ pin path armed
|
||||
// - pinned (any snap) ⇒ unpin path armed
|
||||
// Peek and form snaps are owned by the list-bound gesture.
|
||||
if (!pinnedRef.current && snapRef.current !== 'closed') return;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
direction = null;
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) {
|
||||
// Second finger landed mid-gesture — abort.
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
if (engaged) setLiveDrag(0, false);
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
emitHandle(false, false);
|
||||
return;
|
||||
}
|
||||
if (startY === null) return;
|
||||
|
||||
const delta = e.touches[0].clientY - startY;
|
||||
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||||
const currentPinned = pinnedRef.current;
|
||||
|
||||
// Resolve a direction once the finger crosses the dead-zone.
|
||||
if (direction === null) {
|
||||
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
|
||||
// Horizontal-bail: if |dx| strictly exceeds |dy|, the user is
|
||||
// swiping the mobile tab pager, not pulling the curtain. Drop
|
||||
// tracking so the pager owns the gesture.
|
||||
if (Math.abs(deltaX) > Math.abs(delta)) {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
direction = delta > 0 ? 'down' : 'up';
|
||||
// Direction guards:
|
||||
// - !pinned ⇒ only UP (pin); DOWN has nowhere lower to go
|
||||
// from closed on this surface (peek is owned by the list).
|
||||
// - pinned ⇒ only DOWN (unpin); UP would push the curtain
|
||||
// into the system-tray safe-top zone.
|
||||
if (!currentPinned && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (currentPinned && direction === 'up') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
engaged = true;
|
||||
e.preventDefault();
|
||||
|
||||
if (currentPinned) {
|
||||
// Unpin: 1:1 down, clamped so the curtain doesn't descend
|
||||
// past its `closed` resting top during the drag.
|
||||
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
|
||||
} else {
|
||||
// Pin: 1:1 up, clamped so the curtain doesn't enter the
|
||||
// system-tray safe-top zone.
|
||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
|
||||
}
|
||||
setLiveDrag(lastDelta, true);
|
||||
// Emit handle visual state. Progress is the fraction of
|
||||
// PIN_TRAVEL_PX the curtain has covered (positive for unpin,
|
||||
// sign-flipped for pin so both directions read «how close to
|
||||
// committing»). atCommit flips once the clamp threshold is
|
||||
// reached so the grabber pill stretches + brightens to signal
|
||||
// «release here to confirm».
|
||||
const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX;
|
||||
emitHandle(true, progress >= PIN_COMMIT_THRESHOLD);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!engaged) {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
const currentPinned = pinnedRef.current;
|
||||
// Progress is the fraction of PIN_TRAVEL_PX the curtain visually
|
||||
// covered. For pin (drag-up) `lastDelta` is negative; for unpin
|
||||
// (drag-down) it's positive — both flips use the same magnitude
|
||||
// threshold.
|
||||
const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX;
|
||||
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||||
// setPinned() resets liveDragPx + isDragging in the same
|
||||
// batched update — React renders the curtain at the new
|
||||
// pinned-derived top with the snap transition re-enabled.
|
||||
setPinnedRef.current(!currentPinned);
|
||||
} else {
|
||||
// No commit: drop the live drag back to 0 with transition
|
||||
// active so the curtain springs back to its resting position.
|
||||
setLiveDrag(0, false);
|
||||
}
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
emitHandle(false, false);
|
||||
};
|
||||
|
||||
const onTouchCancel = () => {
|
||||
if (engaged) setLiveDrag(0, false);
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
emitHandle(false, false);
|
||||
};
|
||||
|
||||
handle.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
handle.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
handle.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
handle.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
return () => {
|
||||
handle.removeEventListener('touchstart', onTouchStart);
|
||||
handle.removeEventListener('touchmove', onTouchMove);
|
||||
handle.removeEventListener('touchend', onTouchEnd);
|
||||
handle.removeEventListener('touchcancel', onTouchCancel);
|
||||
};
|
||||
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
|
||||
// `pinned` and `setPinned` are mirrored via the refs above so the
|
||||
// listener (bound once per `disabled` flip) reads fresh values
|
||||
// without re-attaching every render. `disabled` is the only signal
|
||||
// that needs to tear the listeners down — it goes into the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleRef, setLiveDrag, disabled]);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue