diff --git a/README.md b/README.md index 1835ee2..f45270c 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -# react-native-scroll-bottomsheet +# Scroll Bottom Sheet Cross platform scrollable bottom sheet with virtualisation support, running at 60 FPS and fully implemented in JS land ## Installation ```sh -npm i -S react-native-scroll-bottomsheet +npm i -S react-native-scroll-bottom-sheet ``` ## Usage ```js -import ScrollBottomsheet from "react-native-scroll-bottomsheet"; +import ScrollBottomSheet from "react-native-scroll-bottom-sheet"; ``` ## License diff --git a/package-lock.json b/package-lock.json index e6a5ba2..ac02835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { - "name": "react-native-scroll-bottomsheet", - "version": "0.1.0", + "name": "react-native-scroll-bottom-sheet", + "version": "0.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1512,6 +1512,14 @@ } } }, + "@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "requires": { + "@types/hammerjs": "^2.0.36" + } + }, "@hapi/address": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz", @@ -2662,6 +2670,11 @@ "@types/node": "*" } }, + "@types/hammerjs": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.36.tgz", + "integrity": "sha512-7TUK/k2/QGpEAv/BCwSHlYu3NXZhQ9ZwBYpzr9tjlPIL2C5BeGhH3DmVavRx3ZNyELX5TLC91JTz/cen6AAtIQ==" + }, "@types/http-cache-semantics": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", @@ -6906,6 +6919,11 @@ "integrity": "sha512-Y3JFC8PD7eN3KpnrzrmvMAqp0IwnZrmP/oGOptvaSu33d7Zq/8b/2lHlZZkNvRl7/I1Q0umTX8TByK7zzLfTXA==", "dev": true }, + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, "hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", @@ -7189,7 +7207,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -8324,8 +8341,7 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { "version": "3.13.1", @@ -8900,7 +8916,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -12078,8 +12093,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -12944,7 +12958,6 @@ "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -13054,8 +13067,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "react-native": { "version": "0.62.2", @@ -13619,6 +13631,17 @@ } } }, + "react-native-gesture-handler": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-1.6.1.tgz", + "integrity": "sha512-gQgIKhDiYf754yzhhliagLuLupvGb6ZyBdzYzr7aus3Fyi87TLOw63ers+r4kGw0h26oAWTAdHd34JnF4NeL6Q==", + "requires": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^2.3.1", + "invariant": "^2.2.4", + "prop-types": "^15.7.2" + } + }, "react-refresh": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.2.tgz", @@ -16155,6 +16178,11 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==" + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 0185491..71b09ab 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "react-native-scroll-bottomsheet", + "name": "react-native-scroll-bottom-sheet", "version": "0.0.1", "description": "Cross platform scrollable bottom sheet with virtualisation support, running at 60 FPS and fully implemented in JS land", "main": "lib/commonjs/index.js", @@ -23,13 +23,13 @@ "ios", "android" ], - "repository": "https://github.com/rgommezz/react-native-scroll-bottomsheet", - "author": "rgommezz (https://github.com/rgommezz)", + "repository": "https://github.com/rgommezz/react-native-scroll-bottom-sheet", + "author": "Raul Gomez Acuña (https://github.com/rgommezz)", "license": "MIT", "bugs": { - "url": "https://github.com/rgommezz/react-native-scroll-bottomsheet/issues" + "url": "https://github.com/rgommezz/react-native-scroll-bottom-sheet/issues" }, - "homepage": "https://github.com/rgommezz/react-native-scroll-bottomsheet#readme", + "homepage": "https://github.com/rgommezz/react-native-scroll-bottom-sheet#readme", "devDependencies": { "@commitlint/config-conventional": "^8.3.4", "@react-native-community/bob": "^0.10.1", @@ -73,6 +73,7 @@ "prettier" ], "rules": { + "@typescript-eslint/no-unused-vars": "off", "prettier/prettier": [ "error", { @@ -119,5 +120,9 @@ "module", "typescript" ] + }, + "dependencies": { + "react-native-gesture-handler": "^1.6.1", + "utility-types": "^3.10.0" } } diff --git a/src/index.tsx b/src/index.tsx index 6a386e7..167b8bf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,445 @@ -import * as React from 'react'; +import React, { Component } from 'react'; +import { + Animated, + Dimensions, + FlatList, + FlatListProps, + ScrollViewProps, + SectionList, + SectionListProps, + StyleSheet, +} from 'react-native'; +import { + NativeViewGestureHandler, + PanGestureHandler, + PanGestureHandlerProperties, + PanGestureHandlerStateChangeEvent, + ScrollView, + State, +} from 'react-native-gesture-handler'; +import { Assign } from 'utility-types'; + +const FlatListComponentType = 'FlatList' as const; +const ScrollViewComponentType = 'ScrollView' as const; +const SectionListComponentType = 'SectionList' as const; + +const { height: windowHeight } = Dimensions.get('window'); + +type AnimatedScrollableComponent = FlatList | ScrollView | SectionList; + +type FlatListOption = Assign< + { componentType: typeof FlatListComponentType }, + FlatListProps +>; +type ScrollViewOption = Assign< + { componentType: typeof ScrollViewComponentType }, + ScrollViewProps +>; +type SectionListOption = Assign< + { componentType: typeof SectionListComponentType }, + SectionListProps +>; + +type CommonProps = { + /** + * Array of numbers that indicate the different resting positions of the bottom sheet (in dp or %), starting from the top. + */ + snapPoints: Array; + /** + * Index that references the initial settled position of the drawer + */ + initialSnapIndex: number; + /** + * Render prop for the handle + */ + renderHandle: () => React.ReactNode; + /** + * Callback that is executed right after the drawer settles on one of the snapping points. + * The new index is provided on the callback + * @param index + */ + onSettle?: (index: number) => void; + /** + * Animated value that tracks the position of the drawer, being: + * 0 => closed + * 1 => fully opened + */ + animatedPosition?: Animated.Value; + /** + * This value is useful if you want to take into consideration safe area insets + * when applying percentages for snapping points. We recommend using react-native-safe-area-context + * library for that. + * @see https://github.com/th3rdwave/react-native-safe-area-context#usage, insets.top + */ + topInset: number; +}; + +type Props = CommonProps & + (FlatListOption | ScrollViewOption | SectionListOption); + +export class ScrollBottomSheet extends Component> { + static defaultProps = { + topInset: 0, + }; + /** + * Gesture Handler references + */ + private drawerHandleRef = React.createRef(); + private drawerContentRef = React.createRef(); + private scrollComponentRef = React.createRef(); + + /** + * Reference to FlatList, ScrollView or SectionList in order to execute its imperative methods. + */ + private contentComponentRef = React.createRef(); + /** + * Callback executed whenever we start scrolling on the Scrollable component + */ + private onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag']; + /** + * Callback executed per frame, as long as the pan handler is active (like when we are dragging) + */ + private onGestureEvent: PanGestureHandlerProperties['onGestureEvent']; + /** + * Animated value which reflects the amount we drag our finger vertically over the screen. + * Its range is determined by the screen height, being [-SCREEN_HEIGHT, +SCREEN_HEIGHT] + * Negative values indicate dragging the finger up, positive values down + */ + private dragY = new Animated.Value(0); + /** + * Animated value that acts as an accumulator on the Y axis. + */ + private translateYOffset: Animated.Value; + /** + * Animated value that keeps track of how far have we scrolled on the FlatList. + * This is key to determine when should be able to pull down the drawer, which is exactly + * once we reach the top of the FlatList + */ + private lastStartScrollY = new Animated.Value(0); + /** + * Main Animated Value that drives the top position of the UI drawer at any point in time + */ + private translateY: Animated.AnimatedInterpolation; + /** + * The underlying numeric value of lastStartScrollY + */ + private lastStartScrollYValue = 0; + /** + * Last snapping Y position + */ + private lastSnap: number; + /** + * Boolean that indicates whether we are pulling down/up the drawer with the handle. + */ + private isDragWithHandle: boolean = false; + + private didSnapToDifferentThanTopWithHandle: boolean = false; + + /** + * Animated value used to be able to pull down the drawer with the handle + * when the list is not scrolled to the top + */ + private lastEndScrollY: Animated.Value = new Animated.Value(0); + + /** + * Boolean that indicates a continuous gesture that did scroll up and pull down the drawer at the same time + */ + private didScrollUpAndPullDown = false; + + private scrollComponent: React.ComponentType< + FlatListProps | ScrollViewProps | SectionListProps + >; + + convertPercentageToDp = (str: string) => + (Number(str.split('%')[0]) * (windowHeight - this.props.topInset)) / 100; + + constructor(props: Props) { + super(props); + const ScrollComponent = this.getScrollComponent(); + this.scrollComponent = Animated.createAnimatedComponent( + // @ts-ignore + ScrollComponent + ); + const snapPoints = this.getNormalisedSnapPoints(); + const openPosition = snapPoints[0]; + const closedPosition = snapPoints[snapPoints.length - 1]; + this.lastSnap = snapPoints[props.initialSnapIndex]; + + this.translateYOffset = new Animated.Value(this.lastSnap); + this.onGestureEvent = Animated.event( + [{ nativeEvent: { translationY: this.dragY } }], + { useNativeDriver: true } + ); + this.onScrollBeginDrag = Animated.event( + [{ nativeEvent: { contentOffset: { y: this.lastStartScrollY } } }], + { useNativeDriver: true } + ); + + this.lastStartScrollY.addListener(({ value }) => { + this.lastStartScrollYValue = value; + }); + + this.translateY = Animated.add( + Animated.add(this.translateYOffset, this.lastEndScrollY), + Animated.add( + this.dragY, + Animated.multiply(new Animated.Value(-1), this.lastStartScrollY) + ) + ).interpolate({ + inputRange: [openPosition, closedPosition], + outputRange: [openPosition, closedPosition], + extrapolate: 'clamp', + }); + + if (this.props.animatedPosition) { + // We are using timing() and a duration of 0 for rigid tracking + Animated.timing(this.props.animatedPosition, { + // @ts-ignore + toValue: this.translateY.interpolate({ + inputRange: [openPosition, closedPosition], + outputRange: [1, 0], + }), + duration: 0, + }).start(); + } + } + + componentWillUnmount() { + this.lastStartScrollY.removeAllListeners(); + } + + getNormalisedSnapPoints = () => { + return this.props.snapPoints.map((p) => { + if (typeof p === 'string') { + return this.convertPercentageToDp(p); + } else if (typeof p === 'number') { + return p; + } + + throw new Error( + `Invalid type for value ${p}: ${typeof p}. It should be either a percentage string or a number` + ); + }); + }; + + getScrollComponent = () => { + switch (this.props.componentType) { + case 'FlatList': + return FlatList; + case 'ScrollView': + return ScrollView; + case 'SectionList': + return SectionList; + default: + throw new Error( + 'Component type not supported: it should be one of `FlatList`, `ScrollView` or `SectionList`' + ); + } + }; + + onHeaderHandlerStateChange: PanGestureHandlerProperties['onHandlerStateChange'] = ({ + nativeEvent, + }) => { + if (nativeEvent.oldState === State.BEGAN) { + this.isDragWithHandle = true; + // If we pull down the drawer with the handle, we set this value to compensate the amount of scroll on the FlatList + this.lastEndScrollY.setValue(this.lastStartScrollYValue); + } + this.onHandlerStateChange({ nativeEvent }, true); + }; + + getSnapPoint = ( + velocityY: number, + translationY: number, + didScrollUpAndPullDown: boolean = false + ) => { + const snapPoints = this.getNormalisedSnapPoints(); + const extraOffset = didScrollUpAndPullDown ? this.lastStartScrollYValue : 0; + const dragToss = 0.05; + const endOffsetY = this.lastSnap + translationY + dragToss * velocityY; + + let destSnapPoint = snapPoints[0] + extraOffset; + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < snapPoints.length; i++) { + const snapPoint = snapPoints[i] + extraOffset; + const distFromSnap = Math.abs(snapPoint - endOffsetY); + if (distFromSnap < Math.abs(destSnapPoint - endOffsetY)) { + destSnapPoint = snapPoint; + } + } + + return destSnapPoint; + }; + + handleMomentumScrollEnd: ScrollViewProps['onMomentumScrollEnd'] = ({ + nativeEvent: { + contentOffset: { y }, + }, + }) => { + // Updating the position of last scroll after the momentum ends. + if (!this.didScrollUpAndPullDown) { + this.lastStartScrollY.setValue(y); + this.lastStartScrollYValue = y; + } + }; + + resetValues = ( + translationY: number, + destSnapPoint: number, + didScrollUpAndPullDown: boolean + ) => { + this.translateYOffset.extractOffset(); + this.translateYOffset.setValue(translationY); + this.translateYOffset.flattenOffset(); + this.dragY.setValue(0); + this.lastSnap = + destSnapPoint - (didScrollUpAndPullDown ? this.lastStartScrollYValue : 0); + }; + + onHandlerStateChange = ( + { nativeEvent }: PanGestureHandlerStateChangeEvent, + isWithHandle: boolean = false + ) => { + const snapPoints = this.getNormalisedSnapPoints(); + if ( + nativeEvent.oldState === State.ACTIVE && + nativeEvent.state === State.END + ) { + // Translation of the pan gesture along Y axis accumulated over the time of the gesture. + let { velocityY, translationY } = nativeEvent; + if ( + !isWithHandle && + !this.didSnapToDifferentThanTopWithHandle && + !(translationY >= this.lastStartScrollYValue) + ) { + // We offset it with the position of the scroll + translationY -= this.lastStartScrollYValue; + } else if ( + translationY >= this.lastStartScrollYValue && + this.lastStartScrollYValue > 0 + ) { + this.didScrollUpAndPullDown = true; + } + + let destSnapPoint = this.getSnapPoint( + velocityY, + translationY, + this.didScrollUpAndPullDown + ); + + this.resetValues( + translationY, + destSnapPoint, + this.didScrollUpAndPullDown + ); + + Animated.timing(this.translateYOffset, { + toValue: destSnapPoint, + duration: 250, + useNativeDriver: true, + }).start(() => { + // @ts-ignore + this.contentComponentRef.current?._component?.setNativeProps({ + decelerationRate: this.lastSnap === snapPoints[0] ? 0.985 : 0, + }); + if (this.didScrollUpAndPullDown) { + // Compensate values between startScroll (set it to 0) and restore the final amount from translateYOffset; + this.translateYOffset.extractOffset(); + this.translateYOffset.setValue(-this.lastStartScrollYValue); + this.translateYOffset.flattenOffset(); + this.lastStartScrollY.setValue(0); + this.lastStartScrollYValue = 0; + this.didScrollUpAndPullDown = false; + } + + if ( + this.isDragWithHandle && + destSnapPoint !== this.props.snapPoints[0] + ) { + // We have snapped to any snapping point different than top with the handle + this.didSnapToDifferentThanTopWithHandle = true; + } else if ( + this.didSnapToDifferentThanTopWithHandle && + destSnapPoint === snapPoints[0] + ) { + // We have come back to the top snapping point and the list is not scrolled to the top + this.lastEndScrollY.setValue(0); + this.didSnapToDifferentThanTopWithHandle = false; + this.isDragWithHandle = false; + } + + const snapIndex = snapPoints.findIndex((p) => p === destSnapPoint); + if (typeof this.props.onSettle === 'function') { + this.props.onSettle(snapIndex); + } + }); + } + }; -export default class ScrollBottomSheet extends React.Component { render() { - return null; + const { + renderHandle, + snapPoints, + initialSnapIndex, + componentType, + onSettle, + animatedPosition, + ...rest + } = this.props; + const AnimatedScrollableComponent = this.scrollComponent; + + return ( + + + {renderHandle()} + + + + + + + + + + ); } } + +export default ScrollBottomSheet; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 8db1b39..1b4be99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "react-native-scroll-bottomsheet": ["./src/index"] + "react-native-scroll-bottom-sheet": ["./src/index"] }, "allowUnreachableCode": false, "allowUnusedLabels": false,