This commit is contained in:
@@ -37,7 +37,19 @@
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
touch-action: pan-y;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.dragZone {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dragZone:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.handleRow {
|
||||
@@ -46,7 +58,6 @@
|
||||
justify-content: center;
|
||||
padding: 8px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.handle {
|
||||
@@ -106,4 +117,5 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useCallback, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion, useDragControls } from 'framer-motion';
|
||||
import { X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import styles from './BottomSheet.module.css';
|
||||
@@ -164,6 +164,22 @@ export function BottomSheet({
|
||||
|
||||
const targetY = snap === 'full' ? snapFullPx : snapInitialPx;
|
||||
|
||||
// Framer Motion drag controls — we attach drag listening only to a
|
||||
// dedicated "drag zone" (handle + header) rather than the whole
|
||||
// sheet. This keeps the body's native scroll from competing with
|
||||
// the drag gesture, which was the main source of the sluggish/
|
||||
// inconsistent feel on Android: when the finger landed on body
|
||||
// content the browser arbitrated scroll-vs-drag every frame.
|
||||
const dragControls = useDragControls();
|
||||
const dragEnabled = dismissible && draggable;
|
||||
const hasDragZone =
|
||||
dragEnabled && (showHandle || (!disableDefaultHeader && !!title));
|
||||
|
||||
const handleDragZonePointerDown = (e: React.PointerEvent) => {
|
||||
if (!dragEnabled) return;
|
||||
dragControls.start(e);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -187,9 +203,15 @@ export function BottomSheet({
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: targetY }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 36 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 42 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
drag={dismissible && draggable ? 'y' : false}
|
||||
drag={dragEnabled ? 'y' : false}
|
||||
// When a drag zone exists, only it can initiate drag.
|
||||
// Otherwise (rare: no handle + no header) fall back
|
||||
// to dragging anywhere on the sheet.
|
||||
dragListener={hasDragZone ? false : dragEnabled}
|
||||
dragControls={dragEnabled ? dragControls : undefined}
|
||||
dragMomentum={false}
|
||||
dragConstraints={{
|
||||
// Allow dragging UP to the fully-open snap when
|
||||
// the sheet is partial and expandable; otherwise
|
||||
@@ -197,39 +219,33 @@ export function BottomSheet({
|
||||
top: partial && expandable ? snapFullPx - targetY : 0,
|
||||
bottom: snapClosedPx - targetY,
|
||||
}}
|
||||
dragElastic={{ top: partial && expandable ? 0.05 : 0, bottom: 0.2 }}
|
||||
dragElastic={{
|
||||
top: partial && expandable ? 0.12 : 0,
|
||||
bottom: 0.12,
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
const finalY = targetY + info.offset.y;
|
||||
|
||||
// Velocity-driven flicks first — they win over
|
||||
// position-based heuristics so a fast swipe
|
||||
// always does the obvious thing.
|
||||
if (info.velocity.y > 600) {
|
||||
// Strong downward flick → close.
|
||||
// always does the obvious thing. Threshold
|
||||
// lowered from 600 px/s so deliberate (non-
|
||||
// flick) drags also register.
|
||||
if (info.velocity.y > 400) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (partial && expandable && info.velocity.y < -600) {
|
||||
// Strong upward flick → expand to full.
|
||||
if (partial && expandable && info.velocity.y < -400) {
|
||||
setSnap('full');
|
||||
return;
|
||||
}
|
||||
|
||||
// Position-based snap fallback.
|
||||
if (!partial) {
|
||||
// Original behavior — close if dragged
|
||||
// down past the threshold, otherwise
|
||||
// spring back to fully open.
|
||||
if (info.offset.y > 120) onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial sheet position-based snap:
|
||||
// - past the initial position by half its
|
||||
// distance to closed → close
|
||||
// - between snaps → snap to nearest
|
||||
// - above the initial position by 1/3 the
|
||||
// distance to full → expand
|
||||
const closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
|
||||
const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
|
||||
|
||||
@@ -244,24 +260,53 @@ export function BottomSheet({
|
||||
setSnap('initial');
|
||||
}}
|
||||
>
|
||||
{showHandle && (
|
||||
<div className={styles.handleRow}>
|
||||
<div className={styles.handle} />
|
||||
</div>
|
||||
)}
|
||||
{hasDragZone ? (
|
||||
<div
|
||||
className={styles.dragZone}
|
||||
onPointerDown={handleDragZonePointerDown}
|
||||
>
|
||||
{showHandle && (
|
||||
<div className={styles.handleRow}>
|
||||
<div className={styles.handle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!disableDefaultHeader && title && (
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} weight="bold" />
|
||||
</button>
|
||||
{!disableDefaultHeader && title && (
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showHandle && (
|
||||
<div className={styles.handleRow}>
|
||||
<div className={styles.handle} />
|
||||
</div>
|
||||
)}
|
||||
{!disableDefaultHeader && title && (
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.body}>{children}</div>
|
||||
|
||||
Reference in New Issue
Block a user