From 4f2fe090774bcfbfd5171281701cc5ba68db8a44 Mon Sep 17 00:00:00 2001 From: Cody Olsen Date: Tue, 8 Dec 2020 01:35:17 +0100 Subject: [PATCH] fix: improve rubber band effect when out of bounds (#29) --- README.md | 12 +-- package-lock.json | 45 ++++++++- package.json | 1 + pages/fixtures/aside.tsx | 7 ++ pages/fixtures/experiments.tsx | 37 +++++-- pages/fixtures/sticky.tsx | 19 +++- src/BottomSheet.tsx | 177 +++++++++++---------------------- src/hooks/useSnapPoints.tsx | 53 ++++++---- tailwind.config.js | 1 + 9 files changed, 196 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index fc809bb3..8aaa44ab 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm i react-spring-bottom-sheet ## [Basic](https://react-spring-bottom-sheet.cocody.dev/fixtures/simple) -> [View demo code](/pages/fixtures/simple.tsx#L43-L47) +> [View demo code](/pages/fixtures/simple.tsx#L44-L48) MVP example, showing what you get by implementing `open`, `onDismiss` and a single **snap point** always set to `minHeight`. @@ -30,13 +30,13 @@ A more elaborate example that showcases how snap points work. It also shows how ## [Sticky header & footer](https://react-spring-bottom-sheet.cocody.dev/fixtures/sticky) -> [View demo code](/pages/fixtures/sticky.tsx#L40-L60) +> [View demo code](/pages/fixtures/sticky.tsx#L41-L61) If you provide either a `header` or `footer` prop you'll enable the special behavior seen in this example. And they're not just sticky positioned, both areas support touch gestures. ## [Non-blocking overlay mode](https://react-spring-bottom-sheet.cocody.dev/fixtures/aside) -> [View demo code](/pages/fixtures/aside.tsx#L41-L46) +> [View demo code](/pages/fixtures/aside.tsx#L41-L53) In most cases you use a bottom sheet the same way you do with a dialog: you want it to overlay the page and block out distractions. But there are times when you want a bottom sheet but without it taking all the attention and overlaying the entire page. Providing `blocking={false}` helps this use case. By doing so you disable a couple of behaviors that are there for accessibility (focus-locking and more) that prevents a screen reader or a keyboard user from accidentally leaving the bottom sheet. @@ -226,9 +226,9 @@ ef.current.snapTo(({ // Showing all the available props # Credits -- Play icon used on frame overlays: https://fontawesome.com/icons/play-circle?style=regular -- Phone frame used in logo: https://www.figma.com/community/file/896042888090872154/Mono-Devices-1.0 -- iPhone frame used to wrap examples: https://www.figma.com/community/file/858143367356468985/(Variants)-iOS-%26-iPadOS-14-UI-Kit-for-Figma +- Play icon used on frame overlays: [font-awesome](https://fontawesome.com/icons/play-circle?style=regular) +- Phone frame used in logo: [Mono Devices 1.0](https://www.figma.com/community/file/896042888090872154/Mono-Devices-1.0) +- iPhone frame used to wrap examples: [iOS 14 UI Kit for Figma]() [gzip-badge]: http://img.badgesize.io/https://unpkg.com/react-spring-bottom-sheet/dist/index.es.js?compression=gzip&label=gzip%20size&style=flat-square [size-badge]: http://img.badgesize.io/https://unpkg.com/react-spring-bottom-sheet/dist/index.es.js?label=size&style=flat-square diff --git a/package-lock.json b/package-lock.json index f03b945a..cf3e1b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2102,6 +2102,15 @@ } } }, + "@tailwindcss/forms": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.2.1.tgz", + "integrity": "sha512-czfvEdY+J2Ogfd6RUSr/ZSUmDxTujr34M++YLnp2cCPC3oJ4kFvFMaRXA6cEXKw7F1hJuapdjXRjsXIEXGgORg==", + "dev": true, + "requires": { + "mini-svg-data-uri": "^1.2.3" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3158,6 +3167,16 @@ "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", @@ -6449,6 +6468,13 @@ "flat-cache": "^3.0.4" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", @@ -9197,6 +9223,12 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true }, + "mini-svg-data-uri": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.2.3.tgz", + "integrity": "sha512-zd6KCAyXgmq6FV1mR10oKXYtvmA9vRoB6xPSTUJTbFApCtkefDnYueVR1gkof3KcdLZo1Y8mjF2DFmQMIxsHNQ==", + "dev": true + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -9345,6 +9377,13 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "dev": true, + "optional": true + }, "nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", @@ -20515,7 +20554,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } }, "glob-parent": { "version": "3.1.0", diff --git a/package.json b/package.json index 646eeff7..28911b52 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@semantic-release/changelog": "^5.0.1", "@semantic-release/git": "^9.0.0", + "@tailwindcss/forms": "^0.2.1", "@types/classnames": "^2.2.11", "@types/node": "^14.14.10", "@types/react": "^17.0.0", diff --git a/pages/fixtures/aside.tsx b/pages/fixtures/aside.tsx index be5a150a..a001a581 100644 --- a/pages/fixtures/aside.tsx +++ b/pages/fixtures/aside.tsx @@ -42,6 +42,13 @@ const AsideFixturePage: NextPage = ({ open={open} onDismiss={() => setOpen(false)} blocking={false} + header={ + + } snapPoints={({ maxHeight }) => [maxHeight / 4, maxHeight * 0.6]} > diff --git a/pages/fixtures/experiments.tsx b/pages/fixtures/experiments.tsx index 7ddec1a7..84d283d2 100644 --- a/pages/fixtures/experiments.tsx +++ b/pages/fixtures/experiments.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import Button from '../../docs/fixtures/Button' import Code from '../../docs/fixtures/Code' import Container from '../../docs/fixtures/Container' +import Expandable from '../../docs/fixtures/Expandable' import Kbd from '../../docs/fixtures/Kbd' import SheetContent from '../../docs/fixtures/SheetContent' import { BottomSheet } from '../../src' @@ -155,17 +156,39 @@ function Four() { <> [0, minHeight]} + header={ + + } + footer={ + + } > -

- Using onDismiss lets users close the sheet by swiping - it down, tapping on the backdrop or by hitting esc on - their keyboard. -

+ + +
+

Testing focus management and keyboard behavior on open.

+
+ + diff --git a/pages/fixtures/sticky.tsx b/pages/fixtures/sticky.tsx index 271f287f..27a26b0e 100644 --- a/pages/fixtures/sticky.tsx +++ b/pages/fixtures/sticky.tsx @@ -42,7 +42,7 @@ const StickyFixturePage: NextPage = ({ open={open} onDismiss={onDismiss} defaultSnap={({ snapPoints, lastSnap }) => - lastSnap ?? Math.max(...snapPoints) + lastSnap ?? Math.min(...snapPoints) } snapPoints={({ maxHeight }) => [ maxHeight - maxHeight / 5, @@ -67,8 +67,10 @@ const StickyFixturePage: NextPage = ({

- Notice how much better the UX is on resize events when the - "Dismiss" button is sticky. + Putting the "Done" button in a sticky footer is a nice touch on + long bottom sheets with a lot of content. And on resize events + the sticky elements are always visible, unlike the "Dismiss" + button in the first example that needs to be animated first.

@@ -81,6 +83,17 @@ const StickyFixturePage: NextPage = ({ as well to optimize for large phones where the header might be difficult to reach with one hand.

+ +
+

+ Additionally this bottom sheet uses stable viewpoints that are + equivalent to vh CSS units. Predictable heights like this is + also handy if there's content loaded async, or you're + implementing a virtual list so the sheet can't rely on measuring + the height of its content. +

+
+ diff --git a/src/BottomSheet.tsx b/src/BottomSheet.tsx index b2cb613b..b26789f8 100644 --- a/src/BottomSheet.tsx +++ b/src/BottomSheet.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useImperativeHandle, useRef } from 'react' import { animated } from 'react-spring' -import { useDrag } from 'react-use-gesture' +import { rubberbandIfOutOfBounds, useDrag } from 'react-use-gesture' import { useAriaHider, useFocusTrap, @@ -15,8 +15,8 @@ import { useReducedMotion, useScrollLock, useSnapPoints, - useSpringInterpolations, useSpring, + useSpringInterpolations, } from './hooks' import type { defaultSnapProps, @@ -84,7 +84,6 @@ export const BottomSheet = React.forwardRef< const [spring, set] = useSpring() const containerRef = useRef(null) - const backdropRef = useRef(null) const contentRef = useRef(null) const contentContainerRef = useRef(null) const headerRef = useRef(null) @@ -114,15 +113,17 @@ export const BottomSheet = React.forwardRef< }) const { minSnap, maxSnap, maxHeight, findSnap } = useSnapPoints({ + contentContainerRef, + controlledMaxHeight, + footerEnabled: !!footer, + footerRef, getSnapPoints, + headerEnabled: header !== false, + headerRef, heightRef, lastSnapRef, ready, - contentContainerRef, - controlledMaxHeight, registerReady, - footerRef, - headerRef, }) // Setup refs that are used in cases where full control is needed over when a side effect is executed @@ -390,96 +391,68 @@ export const BottomSheet = React.forwardRef< } }, [on, prefersReducedMotion, ready, set]) - const getY = ({ + const handleDrag = ({ + args: [{ closeOnTap = false } = {}] = [], + cancel, + direction: [, direction], down, - temp, - movement, + first, + last, + memo = spring.y.getValue() as number, + movement: [, _my], + tap, velocity, - }: { - down: boolean - temp: number - movement: number - velocity: number - }): number => { - const rawY = temp - movement - const predictedDistance = movement * velocity + }) => { + const my = _my * -1 + + // Cancel the drag operation if the canDrag state changed + if (!canDragRef.current) { + console.log('handleDrag cancelled dragging because canDragRef is false') + springOnResize.current = true + cancel() + return memo + } + + if (onDismiss && closeOnTap && tap) { + cancel() + onDismiss() + return memo + } + + const rawY = memo + my + const predictedDistance = my * velocity const predictedY = Math.max( minSnapRef.current, - Math.min(maxSnapRef.current, rawY - predictedDistance * 2) + Math.min(maxSnapRef.current, rawY + predictedDistance * 2) ) if ( !down && onDismiss && - rawY - predictedDistance < minSnapRef.current / 2 + direction > 0 && + rawY + predictedDistance < minSnapRef.current / 2 ) { + cancel() onDismiss() - return rawY + return memo } - if (down) { - const scale = maxHeight * 0.38196601124999996 - - // If dragging beyond maxSnap it should decay so the user can feel its out of bounds - if (rawY > maxSnapRef.current) { - const overflow = - Math.min(rawY, maxSnapRef.current + scale / 2) - maxSnapRef.current - const resistance = Math.min(0.5, overflow / scale) * overflow + let newY = down + ? rubberbandIfOutOfBounds( + rawY, + onDismiss ? 0 : minSnapRef.current, + maxSnapRef.current, + 0.55 + ) + : predictedY - return maxSnapRef.current + overflow - resistance - } - - // If onDismiss isn't defined, the user can't flick it out of view and the dragging should decay/slow down - if (!onDismiss && rawY < minSnapRef.current) { - const overflow = - minSnapRef.current - Math.max(rawY, minSnapRef.current - scale / 2) - const resistance = Math.min(0.5, overflow / scale) * overflow - - return minSnapRef.current - overflow + resistance - } - - // apply coordinates as it's being dragged, unless it is out of bounds (in which case a decay should be applied) - return rawY - } - - return predictedY - } - - const handleDrag = ({ - down, - velocity, - direction, - memo = spring.y.getValue(), - first, - last, - movement: [, my], - cancel, - }) => { - let newY = getY({ - down: !!down, - movement: isNaN(my) ? 0 : my, - velocity, - temp: memo as number, - }) - - const relativeVelocity = Math.max(1, velocity) - console.log({ first, memo }) if (first) { - console.log('first ', { memo }) springOnResize.current = false } - // Cancel the drag operation if the canDrag state changed - if (!canDragRef.current) { - console.log('handleDrag cancelled dragging because canDragRef is false') - springOnResize.current = true - cancel() - return - } - if (last) { // Restrict y to a valid snap point - newY = findSnap(newY) + newY = findSnapRef.current(newY) heightRef.current = newY lastSnapRef.current = newY springOnResize.current = true @@ -492,31 +465,14 @@ export const BottomSheet = React.forwardRef< maxSnap: maxSnapRef.current, minSnap: minSnapRef.current, immediate: prefersReducedMotion.current || down, - config: { - mass: relativeVelocity, - tension: 300 * relativeVelocity, - friction: 35 * relativeVelocity, - velocity: direction[1] * velocity, - }, + config: { velocity }, }) return memo } - useDrag(handleDrag, { - domTarget: backdropRef, - eventOptions: { capture: true }, - axis: 'y', - }) - useDrag(handleDrag, { - domTarget: headerRef, - eventOptions: { capture: true }, - axis: 'y', - }) - useDrag(handleDrag, { - domTarget: footerRef, - eventOptions: { capture: true }, - axis: 'y', + const bind = useDrag(handleDrag, { + filterTaps: true, }) if (Number.isNaN(maxSnapRef.current)) { @@ -552,23 +508,14 @@ export const BottomSheet = React.forwardRef< }} > {sibling} - {blocking ? ( + {blocking && (
{ - if (onDismiss) { - event.preventDefault() - onDismiss() - } - }} + {...bind({ closeOnTap: true })} /> - ) : ( - // backdropRef always needs to be set because of useDrag -
)}
- {header !== false ? ( -
+ {header !== false && ( +
{header}
- ) : ( - // headerRef always needs to be set because of useDrag -
)}
{children}
- {footer ? ( -
+ {footer && ( +
{footer}
- ) : ( - // footerRef always needs to be set because of useDrag -
)}
diff --git a/src/hooks/useSnapPoints.tsx b/src/hooks/useSnapPoints.tsx index 70d36ca6..695edcfa 100644 --- a/src/hooks/useSnapPoints.tsx +++ b/src/hooks/useSnapPoints.tsx @@ -14,8 +14,10 @@ import { useReady } from './useReady' export function useSnapPoints({ contentContainerRef, controlledMaxHeight, + footerEnabled, footerRef, getSnapPoints, + headerEnabled, headerRef, heightRef, lastSnapRef, @@ -24,8 +26,10 @@ export function useSnapPoints({ }: { contentContainerRef: React.RefObject controlledMaxHeight?: number + footerEnabled: boolean footerRef: React.RefObject getSnapPoints: snapPoints + headerEnabled: boolean headerRef: React.RefObject heightRef: React.RefObject lastSnapRef: React.RefObject @@ -33,10 +37,12 @@ export function useSnapPoints({ registerReady: ReturnType['registerReady'] }) { const { maxHeight, minHeight, headerHeight, footerHeight } = useDimensions({ - controlledMaxHeight, - headerRef, contentContainerRef, + controlledMaxHeight, + footerEnabled, footerRef, + headerEnabled, + headerRef, registerReady, }) @@ -86,16 +92,20 @@ export function useSnapPoints({ } function useDimensions({ - headerRef, contentContainerRef, - footerRef, controlledMaxHeight, + footerEnabled, + footerRef, + headerEnabled, + headerRef, registerReady, }: { - controlledMaxHeight?: number - headerRef: React.RefObject contentContainerRef: React.RefObject + controlledMaxHeight?: number + footerEnabled: boolean footerRef: React.RefObject + headerEnabled: boolean + headerRef: React.RefObject registerReady: ReturnType['registerReady'] }) { const setReady = useMemo(() => registerReady('contentHeight'), [ @@ -103,19 +113,19 @@ function useDimensions({ ]) const maxHeight = useMaxHeight(controlledMaxHeight, registerReady) - // Rewrite these to set refs and use nextTick - const { height: headerHeight } = useElementSizeObserver( - headerRef, - 'headerHeight' - ) + // @TODO probably better to forward props instead of checking refs to decide if it's enabled + const { height: headerHeight } = useElementSizeObserver(headerRef, { + label: 'headerHeight', + enabled: headerEnabled, + }) const { height: contentHeight } = useElementSizeObserver( contentContainerRef, - 'contentHeight' - ) - const { height: footerHeight } = useElementSizeObserver( - footerRef, - 'footerHeight' + { label: 'contentHeight', enabled: true } ) + const { height: footerHeight } = useElementSizeObserver(footerRef, { + label: 'footerHeight', + enabled: footerEnabled, + }) const minHeight = Math.min(maxHeight - headerHeight - footerHeight, contentHeight) + headerHeight + @@ -143,11 +153,12 @@ function useDimensions({ * * @param ref - A React ref to an element */ +const defaultSize = { width: 0, height: 0 } function useElementSizeObserver( ref: React.RefObject, - label: string + { label, enabled }: { label: string; enabled: boolean } ): { width: number; height: number } { - let [size, setSize] = useState(() => ({ width: 0, height: 0 })) + let [size, setSize] = useState(defaultSize) useDebugValue(`${label}: ${size.height}`) @@ -160,7 +171,7 @@ function useElementSizeObserver( }, []) useEffect(() => { - if (!ref.current) { + if (!ref.current || !enabled) { return } @@ -174,9 +185,9 @@ function useElementSizeObserver( return () => { resizeObserver.disconnect() } - }, [ref, handleResize]) + }, [ref, handleResize, enabled]) - return size + return enabled ? size : defaultSize } // Blazingly keep track of the current viewport height without blocking the thread, keeping that sweet 60fps on smartphones diff --git a/tailwind.config.js b/tailwind.config.js index 8d80f8e6..d697486d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -37,4 +37,5 @@ module.exports = { ringWidth: ['group-focus', 'focus-visible'], }, }, + plugins: [require('@tailwindcss/forms')], }