@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
JavaScript
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