UNPKG

@snooper/rn-scroll-bottom-sheet

Version:

Cross platform scrollable bottom sheet with virtualization support, running at 60 FPS and fully implemented in JS land

480 lines (395 loc) 23.3 kB
function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; } function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } import React, { Component } from 'react'; import { Dimensions, FlatList, Platform, ScrollView, SectionList, StyleSheet, View } from 'react-native'; import Animated, { abs, add, and, call, Clock, clockRunning, cond, Easing, eq, event, Extrapolate, greaterOrEq, greaterThan, interpolate, multiply, not, onChange, or, set, startClock, stopClock, sub, timing, Value } from 'react-native-reanimated'; import { NativeViewGestureHandler, PanGestureHandler, State as GestureState, TapGestureHandler } from 'react-native-gesture-handler'; const FlatListComponentType = 'FlatList'; const ScrollViewComponentType = 'ScrollView'; const SectionListComponentType = 'SectionList'; const { height: windowHeight } = Dimensions.get('window'); const DRAG_TOSS = 0.05; const IOS_NORMAL_DECELERATION_RATE = 0.998; const ANDROID_NORMAL_DECELERATION_RATE = 0.985; const DEFAULT_ANIMATION_DURATION = 250; const DEFAULT_EASING = Easing.inOut(Easing.linear); const imperativeScrollOptions = { [FlatListComponentType]: { method: 'scrollToIndex', args: { index: 0, viewPosition: 0, viewOffset: 1000, animated: true } }, [ScrollViewComponentType]: { method: 'scrollTo', args: { x: 0, y: 0, animated: true } }, [SectionListComponentType]: { method: 'scrollToLocation', args: { itemIndex: 0, sectionIndex: 0, viewPosition: 0, viewOffset: 1000, animated: true } } }; export class ScrollBottomSheet extends Component { /** * Gesture Handler references */ /** * ScrollView prop */ /** * Pan gesture handler events for drawer handle and content */ /** * Main Animated Value that drives the top position of the UI drawer at any point in time */ /** * Animated value that keeps track of the position: 0 => closed, 1 => opened */ /** * Flag to indicate imperative snapping */ /** * Manual snapping amount */ /** * Keeps track of the current index */ /** * Deceleration rate of the scroll component. This is used only on Android to * compensate the unexpected glide it gets sometimes. */ constructor(props) { super(props); _defineProperty(this, "masterDrawer", /*#__PURE__*/React.createRef()); _defineProperty(this, "drawerHandleRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "overlayComponentRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "drawerContentRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "scrollComponentRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "onScrollBeginDrag", void 0); _defineProperty(this, "onHandleGestureEvent", void 0); _defineProperty(this, "onDrawerGestureEvent", void 0); _defineProperty(this, "translateY", void 0); _defineProperty(this, "position", void 0); _defineProperty(this, "isManuallySetValue", new Value(0)); _defineProperty(this, "manualYOffset", new Value(0)); _defineProperty(this, "nextSnapIndex", void 0); _defineProperty(this, "decelerationRate", void 0); _defineProperty(this, "prevSnapIndex", -1); _defineProperty(this, "dragY", new Value(0)); _defineProperty(this, "prevDragY", new Value(0)); _defineProperty(this, "tempDestSnapPoint", new Value(0)); _defineProperty(this, "isAndroid", new Value(Number(Platform.OS === 'android'))); _defineProperty(this, "animationClock", new Clock()); _defineProperty(this, "animationPosition", new Value(0)); _defineProperty(this, "animationFinished", new Value(0)); _defineProperty(this, "animationFrameTime", new Value(0)); _defineProperty(this, "velocityY", new Value(0)); _defineProperty(this, "lastStartScrollY", new Value(0)); _defineProperty(this, "prevTranslateYOffset", void 0); _defineProperty(this, "translationY", void 0); _defineProperty(this, "destSnapPoint", new Value(0)); _defineProperty(this, "lastSnap", void 0); _defineProperty(this, "dragWithHandle", new Value(0)); _defineProperty(this, "scrollUpAndPullDown", new Value(0)); _defineProperty(this, "didGestureFinish", void 0); _defineProperty(this, "didScrollUpAndPullDown", void 0); _defineProperty(this, "setTranslationY", void 0); _defineProperty(this, "extraOffset", void 0); _defineProperty(this, "calculateNextSnapPoint", void 0); _defineProperty(this, "scrollComponent", void 0); _defineProperty(this, "convertPercentageToDp", str => Number(str.split('%')[0]) * (windowHeight - this.props.topInset) / 100); _defineProperty(this, "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 ".concat(p, ": ").concat(typeof p, ". It should be either a percentage string or a number")); }); }); _defineProperty(this, "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`'); } }); _defineProperty(this, "snapTo", index => { const snapPoints = this.getNormalisedSnapPoints(); this.isManuallySetValue.setValue(1); this.manualYOffset.setValue(snapPoints[index]); this.nextSnapIndex.setValue(index); }); const { initialSnapIndex, animationConfig } = props; const animationDuration = (animationConfig === null || animationConfig === void 0 ? void 0 : animationConfig.duration) || DEFAULT_ANIMATION_DURATION; const ScrollComponent = this.getScrollComponent(); // @ts-ignore this.scrollComponent = Animated.createAnimatedComponent(ScrollComponent); const _snapPoints = this.getNormalisedSnapPoints(); const openPosition = _snapPoints[0]; const closedPosition = _snapPoints[_snapPoints.length - 1]; const initialSnap = _snapPoints[initialSnapIndex]; this.nextSnapIndex = new Value(initialSnapIndex); const initialDecelerationRate = Platform.select({ android: props.initialSnapIndex === 0 ? ANDROID_NORMAL_DECELERATION_RATE : 0, ios: IOS_NORMAL_DECELERATION_RATE }); this.decelerationRate = new Value(initialDecelerationRate); const handleGestureState = new Value(-1); const handleOldGestureState = new Value(-1); const drawerGestureState = new Value(-1); const drawerOldGestureState = new Value(-1); const lastSnapInRange = new Value(1); this.prevTranslateYOffset = new Value(initialSnap); this.translationY = new Value(initialSnap); this.lastSnap = new Value(initialSnap); this.onHandleGestureEvent = event([{ nativeEvent: { translationY: this.dragY, oldState: handleOldGestureState, state: handleGestureState, velocityY: this.velocityY } }]); this.onDrawerGestureEvent = event([{ nativeEvent: { translationY: this.dragY, oldState: drawerOldGestureState, state: drawerGestureState, velocityY: this.velocityY } }]); this.onScrollBeginDrag = event([{ nativeEvent: { contentOffset: { y: this.lastStartScrollY } } }]); const didHandleGestureBegin = eq(handleGestureState, GestureState.ACTIVE); const isAnimationInterrupted = and(or(eq(handleGestureState, GestureState.BEGAN), eq(drawerGestureState, GestureState.BEGAN)), clockRunning(this.animationClock)); this.didGestureFinish = or(and(eq(handleOldGestureState, GestureState.ACTIVE), eq(handleGestureState, GestureState.END)), and(eq(drawerOldGestureState, GestureState.ACTIVE), eq(drawerGestureState, GestureState.END))); // Function that determines if the last snap point is in the range {snapPoints} // In the case of interruptions in the middle of an animation, we'll get // lastSnap values outside the range const isLastSnapPointInRange = (i = 0) => i === _snapPoints.length ? lastSnapInRange : cond(eq(this.lastSnap, _snapPoints[i]), [set(lastSnapInRange, 1)], isLastSnapPointInRange(i + 1)); const scrollY = [set(lastSnapInRange, 0), isLastSnapPointInRange(), cond(or(didHandleGestureBegin, and(this.isManuallySetValue, not(eq(this.manualYOffset, _snapPoints[0])))), [set(this.dragWithHandle, 1), 0]), cond( // This is to account for a continuous scroll on the drawer from a snap point // Different than top, bringing the drawer to the top position, so that if we // change scroll direction without releasing the gesture, it doesn't pull down the drawer again and(eq(this.dragWithHandle, 1), greaterThan(_snapPoints[0], add(this.lastSnap, this.dragY)), and(not(eq(this.lastSnap, _snapPoints[0])), lastSnapInRange)), [set(this.lastSnap, _snapPoints[0]), set(this.dragWithHandle, 0), this.lastStartScrollY], cond(eq(this.dragWithHandle, 1), 0, this.lastStartScrollY))]; this.didScrollUpAndPullDown = cond(and(greaterOrEq(this.dragY, this.lastStartScrollY), greaterThan(this.lastStartScrollY, 0)), set(this.scrollUpAndPullDown, 1)); this.setTranslationY = cond(and(not(this.dragWithHandle), not(greaterOrEq(this.dragY, this.lastStartScrollY))), set(this.translationY, sub(this.dragY, this.lastStartScrollY)), set(this.translationY, this.dragY)); this.extraOffset = cond(eq(this.scrollUpAndPullDown, 1), this.lastStartScrollY, 0); const endOffsetY = add(this.lastSnap, this.translationY, multiply(DRAG_TOSS, this.velocityY)); this.calculateNextSnapPoint = (i = 0) => i === _snapPoints.length ? this.tempDestSnapPoint : cond(greaterThan(abs(sub(this.tempDestSnapPoint, endOffsetY)), abs(sub(add(_snapPoints[i], this.extraOffset), endOffsetY))), [set(this.tempDestSnapPoint, add(_snapPoints[i], this.extraOffset)), set(this.nextSnapIndex, i), this.calculateNextSnapPoint(i + 1)], this.calculateNextSnapPoint(i + 1)); const runTiming = ({ clock, from, to, position, finished, frameTime }) => { const state = { finished, position, time: new Value(0), frameTime }; const animationParams = { duration: animationDuration, easing: (animationConfig === null || animationConfig === void 0 ? void 0 : animationConfig.easing) || DEFAULT_EASING }; const config = _objectSpread({ toValue: new Value(0) }, animationParams); return [cond(and(not(clockRunning(clock)), not(eq(finished, 1))), [// If the clock isn't running, we reset all the animation params and start the clock set(state.finished, 0), set(state.time, 0), set(state.position, from), set(state.frameTime, 0), set(config.toValue, to), startClock(clock)]), // We run the step here that is going to update position timing(clock, state, config), cond(state.finished, [call([this.nextSnapIndex], ([value]) => { if (value !== this.prevSnapIndex) { var _this$props$onSettle, _this$props; (_this$props$onSettle = (_this$props = this.props).onSettle) === null || _this$props$onSettle === void 0 ? void 0 : _this$props$onSettle.call(_this$props, value); } this.prevSnapIndex = value; }), // Resetting appropriate values set(drawerOldGestureState, GestureState.END), set(handleOldGestureState, GestureState.END), set(this.prevTranslateYOffset, state.position), cond(eq(this.scrollUpAndPullDown, 1), [set(this.prevTranslateYOffset, sub(this.prevTranslateYOffset, this.lastStartScrollY)), set(this.lastStartScrollY, 0), set(this.scrollUpAndPullDown, 0)]), cond(eq(this.destSnapPoint, _snapPoints[0]), [set(this.dragWithHandle, 0)]), set(this.isManuallySetValue, 0), set(this.manualYOffset, 0), stopClock(clock), this.prevTranslateYOffset], // We made the block return the updated position, state.position)]; }; const translateYOffset = cond(isAnimationInterrupted, [// set(prevTranslateYOffset, animationPosition) should only run if we are // interrupting an animation when the drawer is currently in a different // position than the top cond(or(this.dragWithHandle, greaterOrEq(abs(this.prevDragY), this.lastStartScrollY)), set(this.prevTranslateYOffset, this.animationPosition)), set(this.animationFinished, 1), set(this.translationY, 0), // Resetting appropriate values set(drawerOldGestureState, GestureState.END), set(handleOldGestureState, GestureState.END), // By forcing that frameTime exceeds duration, it has the effect of stopping the animation set(this.animationFrameTime, add(animationDuration, 1000)), stopClock(this.animationClock), this.prevTranslateYOffset], cond(or(this.didGestureFinish, this.isManuallySetValue, clockRunning(this.animationClock)), [runTiming({ clock: this.animationClock, from: cond(this.isManuallySetValue, this.prevTranslateYOffset, add(this.prevTranslateYOffset, this.translationY)), to: this.destSnapPoint, position: this.animationPosition, finished: this.animationFinished, frameTime: this.animationFrameTime })], [set(this.animationFrameTime, 0), set(this.animationFinished, 0), // @ts-ignore this.prevTranslateYOffset])); this.translateY = interpolate(add(translateYOffset, this.dragY, multiply(scrollY, -1)), { inputRange: [openPosition, closedPosition], outputRange: [openPosition, closedPosition], extrapolate: Extrapolate.CLAMP }); this.position = interpolate(this.translateY, { inputRange: [openPosition, closedPosition], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP }); } render() { const _this$props2 = this.props, { renderHandle, snapPoints, initialSnapIndex, componentType, onSettle, animatedPosition, containerStyle, renderOverlappingComponent } = _this$props2, rest = _objectWithoutProperties(_this$props2, ["renderHandle", "snapPoints", "initialSnapIndex", "componentType", "onSettle", "animatedPosition", "containerStyle", "renderOverlappingComponent"]); const AnimatedScrollableComponent = this.scrollComponent; const normalisedSnapPoints = this.getNormalisedSnapPoints(); const initialSnap = normalisedSnapPoints[initialSnapIndex]; const Content = /*#__PURE__*/React.createElement(Animated.View, { style: [StyleSheet.absoluteFillObject, containerStyle, // @ts-ignore { transform: [{ translateY: this.translateY }] }] }, /*#__PURE__*/React.createElement(PanGestureHandler, { ref: this.drawerHandleRef, shouldCancelWhenOutside: false, simultaneousHandlers: this.masterDrawer, onGestureEvent: this.onHandleGestureEvent, onHandlerStateChange: this.onHandleGestureEvent }, /*#__PURE__*/React.createElement(Animated.View, null, renderHandle())), /*#__PURE__*/React.createElement(PanGestureHandler, { ref: this.drawerContentRef, simultaneousHandlers: [this.scrollComponentRef, this.masterDrawer], shouldCancelWhenOutside: false, onGestureEvent: this.onDrawerGestureEvent, onHandlerStateChange: this.onDrawerGestureEvent }, /*#__PURE__*/React.createElement(Animated.View, { style: styles.container }, /*#__PURE__*/React.createElement(NativeViewGestureHandler, { ref: this.scrollComponentRef, waitFor: this.masterDrawer, simultaneousHandlers: this.drawerContentRef }, /*#__PURE__*/React.createElement(AnimatedScrollableComponent, _extends({ overScrollMode: "never", bounces: false }, rest, { ref: this.props.innerRef // @ts-ignore , decelerationRate: this.decelerationRate, onScrollBeginDrag: this.onScrollBeginDrag, scrollEventThrottle: 1, contentContainerStyle: [rest.contentContainerStyle, { paddingBottom: this.getNormalisedSnapPoints()[0] }] }))))), /*#__PURE__*/React.createElement(PanGestureHandler, { ref: this.overlayComponentRef, shouldCancelWhenOutside: false, simultaneousHandlers: this.masterDrawer, onGestureEvent: this.onHandleGestureEvent, onHandlerStateChange: this.onHandleGestureEvent }, /*#__PURE__*/React.createElement(Animated.View, null, renderOverlappingComponent())), this.props.animatedPosition && /*#__PURE__*/React.createElement(Animated.Code, { exec: onChange(this.position, set(this.props.animatedPosition, this.position)) }), /*#__PURE__*/React.createElement(Animated.Code, { exec: onChange(this.dragY, cond(not(eq(this.dragY, 0)), set(this.prevDragY, this.dragY))) }), /*#__PURE__*/React.createElement(Animated.Code, { exec: onChange(this.didGestureFinish, cond(this.didGestureFinish, [this.didScrollUpAndPullDown, this.setTranslationY, set(this.tempDestSnapPoint, add(normalisedSnapPoints[0], this.extraOffset)), set(this.nextSnapIndex, 0), set(this.destSnapPoint, this.calculateNextSnapPoint()), cond(and(greaterThan(this.dragY, this.lastStartScrollY), this.isAndroid, not(this.dragWithHandle)), call([], () => { var _this$props3, _this$props3$data; // This prevents the scroll glide from happening on Android when pulling down with inertia. // It's not perfect, but does the job for now const { method, args } = imperativeScrollOptions[this.props.componentType]; if (this.props.componentType === 'FlatList' && (((_this$props3 = this.props) === null || _this$props3 === void 0 ? void 0 : (_this$props3$data = _this$props3.data) === null || _this$props3$data === void 0 ? void 0 : _this$props3$data.length) || 0) > 0 || this.props.componentType === 'SectionList' && this.props.sections.length > 0 || this.props.componentType === 'ScrollView') { var _this$props$innerRef$; // @ts-ignore (_this$props$innerRef$ = this.props.innerRef.current) === null || _this$props$innerRef$ === void 0 ? void 0 : _this$props$innerRef$.getNode()[method](args); } })), set(this.dragY, 0), set(this.velocityY, 0), set(this.lastSnap, sub(this.destSnapPoint, cond(eq(this.scrollUpAndPullDown, 1), this.lastStartScrollY, 0))), call([this.lastSnap], ([value]) => { var _this$masterDrawer, _this$masterDrawer$cu; // This is the TapGHandler trick // @ts-ignore (_this$masterDrawer = this.masterDrawer) === null || _this$masterDrawer === void 0 ? void 0 : (_this$masterDrawer$cu = _this$masterDrawer.current) === null || _this$masterDrawer$cu === void 0 ? void 0 : _this$masterDrawer$cu.setNativeProps({ maxDeltaY: value - this.getNormalisedSnapPoints()[0] }); }), set(this.decelerationRate, cond(eq(this.isAndroid, 1), cond(eq(this.lastSnap, normalisedSnapPoints[0]), ANDROID_NORMAL_DECELERATION_RATE, 0), IOS_NORMAL_DECELERATION_RATE))])) }), /*#__PURE__*/React.createElement(Animated.Code, { exec: onChange(this.isManuallySetValue, [cond(this.isManuallySetValue, [set(this.destSnapPoint, this.manualYOffset), set(this.animationFinished, 0), set(this.lastSnap, this.manualYOffset), call([this.lastSnap], ([value]) => { var _this$masterDrawer2, _this$masterDrawer2$c; // This is the TapGHandler trick // @ts-ignore (_this$masterDrawer2 = this.masterDrawer) === null || _this$masterDrawer2 === void 0 ? void 0 : (_this$masterDrawer2$c = _this$masterDrawer2.current) === null || _this$masterDrawer2$c === void 0 ? void 0 : _this$masterDrawer2$c.setNativeProps({ maxDeltaY: value - this.getNormalisedSnapPoints()[0] }); })], [set(this.nextSnapIndex, 0)])]) })); // On Android, having an intermediary view with pointerEvents="box-none", breaks the // waitFor logic if (Platform.OS === 'android') { return /*#__PURE__*/React.createElement(TapGestureHandler, { maxDurationMs: 100000, ref: this.masterDrawer, maxDeltaY: initialSnap - this.getNormalisedSnapPoints()[0], shouldCancelWhenOutside: false }, Content); } // On iOS, We need to wrap the content on a view with PointerEvents box-none // So that we can start scrolling automatically when reaching the top without // Stopping the gesture return /*#__PURE__*/React.createElement(TapGestureHandler, { maxDurationMs: 100000, ref: this.masterDrawer, maxDeltaY: initialSnap - this.getNormalisedSnapPoints()[0] }, /*#__PURE__*/React.createElement(View, { style: StyleSheet.absoluteFillObject, pointerEvents: "box-none" }, Content)); } } _defineProperty(ScrollBottomSheet, "defaultProps", { topInset: 0, innerRef: /*#__PURE__*/React.createRef() }); export default ScrollBottomSheet; const styles = StyleSheet.create({ container: { flex: 1 } }); //# sourceMappingURL=index.js.map