UNPKG

19.5 kBTypeScriptView Raw
1import * as React from 'react';
2import {
3 StyleSheet,
4 ViewStyle,
5 LayoutChangeEvent,
6 I18nManager,
7 Platform,
8 Keyboard,
9 StatusBar,
10} from 'react-native';
11import {
12 PanGestureHandler,
13 TapGestureHandler,
14 State,
15} from 'react-native-gesture-handler';
16import Animated from 'react-native-reanimated';
17import DrawerProgressContext from '../utils/DrawerProgressContext';
18
19const {
20 Clock,
21 Value,
22 onChange,
23 clockRunning,
24 startClock,
25 stopClock,
26 interpolate,
27 spring,
28 abs,
29 add,
30 and,
31 block,
32 call,
33 cond,
34 divide,
35 eq,
36 event,
37 greaterThan,
38 lessThan,
39 max,
40 min,
41 multiply,
42 neq,
43 or,
44 set,
45 sub,
46} = Animated;
47
48const TRUE = 1;
49const FALSE = 0;
50const NOOP = 0;
51const UNSET = -1;
52
53const PROGRESS_EPSILON = 0.05;
54
55const DIRECTION_LEFT = 1;
56const DIRECTION_RIGHT = -1;
57
58const SWIPE_DISTANCE_THRESHOLD_DEFAULT = 60;
59
60const SWIPE_DISTANCE_MINIMUM = 5;
61
62const SPRING_CONFIG = {
63 stiffness: 1000,
64 damping: 500,
65 mass: 3,
66 overshootClamping: true,
67 restDisplacementThreshold: 0.01,
68 restSpeedThreshold: 0.01,
69};
70
71type Binary = 0 | 1;
72
73type Renderer = (props: { progress: Animated.Node<number> }) => React.ReactNode;
74
75type Props = {
76 open: boolean;
77 onOpen: () => void;
78 onClose: () => void;
79 onGestureRef?: (ref: PanGestureHandler | null) => void;
80 gestureEnabled: boolean;
81 drawerPosition: 'left' | 'right';
82 drawerType: 'front' | 'back' | 'slide';
83 keyboardDismissMode: 'none' | 'on-drag';
84 swipeEdgeWidth: number;
85 swipeDistanceThreshold?: number;
86 swipeVelocityThreshold: number;
87 hideStatusBar: boolean;
88 statusBarAnimation: 'slide' | 'none' | 'fade';
89 overlayStyle?: ViewStyle;
90 drawerStyle?: ViewStyle;
91 sceneContainerStyle?: ViewStyle;
92 renderDrawerContent: Renderer;
93 renderSceneContent: Renderer;
94 gestureHandlerProps?: React.ComponentProps<typeof PanGestureHandler>;
95};
96
97export default class Drawer extends React.PureComponent<Props> {
98 static defaultProps = {
99 gestureEnabled: true,
100 drawerPostion: I18nManager.isRTL ? 'left' : 'right',
101 drawerType: 'front',
102 swipeEdgeWidth: 32,
103 swipeVelocityThreshold: 500,
104 keyboardDismissMode: 'on-drag',
105 hideStatusBar: false,
106 statusBarAnimation: 'slide',
107 };
108
109 componentDidUpdate(prevProps: Props) {
110 const {
111 open,
112 drawerPosition,
113 drawerType,
114 swipeDistanceThreshold,
115 swipeVelocityThreshold,
116 hideStatusBar,
117 } = this.props;
118
119 if (
120 // If we're not in the middle of a transition, sync the drawer's open state
121 typeof this.pendingOpenValue !== 'boolean' ||
122 open !== this.pendingOpenValue
123 ) {
124 this.toggleDrawer(open);
125 }
126
127 this.pendingOpenValue = undefined;
128
129 if (open !== prevProps.open && hideStatusBar) {
130 this.toggleStatusBar(open);
131 }
132
133 if (prevProps.drawerPosition !== drawerPosition) {
134 this.drawerPosition.setValue(
135 drawerPosition === 'right' ? DIRECTION_RIGHT : DIRECTION_LEFT
136 );
137 }
138
139 if (prevProps.drawerType !== drawerType) {
140 this.isDrawerTypeFront.setValue(drawerType === 'front' ? TRUE : FALSE);
141 }
142
143 if (prevProps.swipeDistanceThreshold !== swipeDistanceThreshold) {
144 this.swipeDistanceThreshold.setValue(
145 swipeDistanceThreshold !== undefined
146 ? swipeDistanceThreshold
147 : SWIPE_DISTANCE_THRESHOLD_DEFAULT
148 );
149 }
150
151 if (prevProps.swipeVelocityThreshold !== swipeVelocityThreshold) {
152 this.swipeVelocityThreshold.setValue(swipeVelocityThreshold);
153 }
154 }
155
156 componentWillUnmount() {
157 this.toggleStatusBar(false);
158 }
159
160 private clock = new Clock();
161
162 private isDrawerTypeFront = new Value<Binary>(
163 this.props.drawerType === 'front' ? TRUE : FALSE
164 );
165
166 private isOpen = new Value<Binary>(this.props.open ? TRUE : FALSE);
167 private nextIsOpen = new Value<Binary | -1>(UNSET);
168 private isSwiping = new Value<Binary>(FALSE);
169
170 private gestureState = new Value<number>(State.UNDETERMINED);
171 private touchX = new Value<number>(0);
172 private velocityX = new Value<number>(0);
173 private gestureX = new Value<number>(0);
174 private offsetX = new Value<number>(0);
175 private position = new Value<number>(0);
176
177 private containerWidth = new Value<number>(0);
178 private drawerWidth = new Value<number>(0);
179 private drawerOpacity = new Value<number>(0);
180 private drawerPosition = new Value<number>(
181 this.props.drawerPosition === 'right' ? DIRECTION_RIGHT : DIRECTION_LEFT
182 );
183
184 // Comment stolen from react-native-gesture-handler/DrawerLayout
185 //
186 // While closing the drawer when user starts gesture outside of its area (in greyed
187 // out part of the window), we want the drawer to follow only once finger reaches the
188 // edge of the drawer.
189 // E.g. on the diagram below drawer is illustrate by X signs and the greyed out area by
190 // dots. The touch gesture starts at '*' and moves left, touch path is indicated by
191 // an arrow pointing left
192 // 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+
193 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
194 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
195 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
196 // |XXXXXXXX|......| |XXXXXXXX|.<-*..| |XXXXXXXX|<--*..| |XXXXX|<-----*..|
197 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
198 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
199 // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
200 // +---------------+ +---------------+ +---------------+ +---------------+
201 //
202 // For the above to work properly we define animated value that will keep start position
203 // of the gesture. Then we use that value to calculate how much we need to subtract from
204 // the dragX. If the gesture started on the greyed out area we take the distance from the
205 // edge of the drawer to the start position. Otherwise we don't subtract at all and the
206 // drawer be pulled back as soon as you start the pan.
207 //
208 // This is used only when drawerType is "front"
209 private touchDistanceFromDrawer = cond(
210 this.isDrawerTypeFront,
211 cond(
212 eq(this.drawerPosition, DIRECTION_LEFT),
213 max(
214 // Distance of touch start from left screen edge - Drawer width
215 sub(sub(this.touchX, this.gestureX), this.drawerWidth),
216 0
217 ),
218 min(
219 multiply(
220 // Distance of drawer from left screen edge - Touch start point
221 sub(
222 sub(this.containerWidth, this.drawerWidth),
223 sub(this.touchX, this.gestureX)
224 ),
225 DIRECTION_RIGHT
226 ),
227 0
228 )
229 ),
230 0
231 );
232
233 private swipeDistanceThreshold = new Value<number>(
234 this.props.swipeDistanceThreshold !== undefined
235 ? this.props.swipeDistanceThreshold
236 : SWIPE_DISTANCE_THRESHOLD_DEFAULT
237 );
238 private swipeVelocityThreshold = new Value<number>(
239 this.props.swipeVelocityThreshold
240 );
241
242 private currentOpenValue: boolean = this.props.open;
243 private pendingOpenValue: boolean | undefined;
244
245 private isStatusBarHidden: boolean = false;
246
247 private manuallyTriggerSpring = new Value<Binary>(FALSE);
248
249 private transitionTo = (isOpen: number | Animated.Node<number>) => {
250 const toValue = new Value(0);
251 const frameTime = new Value(0);
252
253 const state = {
254 position: this.position,
255 time: new Value(0),
256 finished: new Value(FALSE),
257 velocity: new Value(0),
258 };
259
260 return block([
261 cond(clockRunning(this.clock), NOOP, [
262 // Animation wasn't running before
263 // Set the initial values and start the clock
264 set(toValue, multiply(isOpen, this.drawerWidth, this.drawerPosition)),
265 set(frameTime, 0),
266 set(state.time, 0),
267 set(state.finished, FALSE),
268 set(state.velocity, this.velocityX),
269 set(this.isOpen, isOpen),
270 startClock(this.clock),
271 set(this.manuallyTriggerSpring, FALSE),
272 ]),
273 spring(this.clock, state, { ...SPRING_CONFIG, toValue }),
274 cond(state.finished, [
275 // Reset gesture and velocity from previous gesture
276 set(this.touchX, 0),
277 set(this.gestureX, 0),
278 set(this.velocityX, 0),
279 set(this.offsetX, 0),
280 // When the animation finishes, stop the clock
281 stopClock(this.clock),
282 call([this.isOpen], ([value]: readonly Binary[]) => {
283 const open = Boolean(value);
284
285 if (open !== this.props.open) {
286 // Sync drawer's state after animation finished
287 // This shouldn't be necessary, but there seems to be an issue on iOS
288 this.toggleDrawer(this.props.open);
289 }
290 }),
291 ]),
292 ]);
293 };
294
295 private dragX = block([
296 onChange(
297 this.isOpen,
298 call([this.isOpen], ([value]: readonly Binary[]) => {
299 const open = Boolean(value);
300
301 this.currentOpenValue = open;
302
303 // Without this check, the drawer can go to an infinite update <-> animate loop for sync updates
304 if (open !== this.props.open) {
305 // If the mode changed, update state
306 if (open) {
307 this.props.onOpen();
308 } else {
309 this.props.onClose();
310 }
311
312 this.pendingOpenValue = open;
313
314 // Force componentDidUpdate to fire, whether user does a setState or not
315 // This allows us to detect when the user drops the update and revert back
316 // It's necessary to make sure that the state stays in sync
317 this.forceUpdate();
318 }
319 })
320 ),
321 onChange(
322 this.nextIsOpen,
323 cond(neq(this.nextIsOpen, UNSET), [
324 // Stop any running animations
325 cond(clockRunning(this.clock), stopClock(this.clock)),
326 // Update the open value to trigger the transition
327 set(this.isOpen, this.nextIsOpen),
328 set(this.gestureX, 0),
329 set(this.nextIsOpen, UNSET),
330 ])
331 ),
332 // This block must be after the this.isOpen listener since we check for current value
333 onChange(
334 this.isSwiping,
335 // Listen to updates for this value only when it changes
336 // Without `onChange`, this will fire even if the value didn't change
337 // We don't want to call the listeners if the value didn't change
338 call([this.isSwiping], ([value]: readonly Binary[]) => {
339 const { keyboardDismissMode } = this.props;
340
341 if (value === TRUE) {
342 if (keyboardDismissMode === 'on-drag') {
343 Keyboard.dismiss();
344 }
345
346 this.toggleStatusBar(true);
347 } else {
348 this.toggleStatusBar(this.currentOpenValue);
349 }
350 })
351 ),
352 cond(
353 eq(this.gestureState, State.ACTIVE),
354 [
355 cond(this.isSwiping, NOOP, [
356 // We weren't dragging before, set it to true
357 set(this.isSwiping, TRUE),
358 // Also update the drag offset to the last position
359 set(this.offsetX, this.position),
360 ]),
361 // Update position with previous offset + gesture distance
362 set(
363 this.position,
364 add(this.offsetX, this.gestureX, this.touchDistanceFromDrawer)
365 ),
366 // Stop animations while we're dragging
367 stopClock(this.clock),
368 ],
369 [
370 set(this.isSwiping, FALSE),
371 set(this.touchX, 0),
372 this.transitionTo(
373 cond(
374 this.manuallyTriggerSpring,
375 this.isOpen,
376 cond(
377 or(
378 and(
379 greaterThan(abs(this.gestureX), SWIPE_DISTANCE_MINIMUM),
380 greaterThan(abs(this.velocityX), this.swipeVelocityThreshold)
381 ),
382 greaterThan(abs(this.gestureX), this.swipeDistanceThreshold)
383 ),
384 cond(
385 eq(this.drawerPosition, DIRECTION_LEFT),
386 // If swiped to right, open the drawer, otherwise close it
387 greaterThan(
388 cond(eq(this.velocityX, 0), this.gestureX, this.velocityX),
389 0
390 ),
391 // If swiped to left, open the drawer, otherwise close it
392 lessThan(
393 cond(eq(this.velocityX, 0), this.gestureX, this.velocityX),
394 0
395 )
396 ),
397 this.isOpen
398 )
399 )
400 ),
401 ]
402 ),
403 this.position,
404 ]);
405
406 private translateX = cond(
407 eq(this.drawerPosition, DIRECTION_RIGHT),
408 min(max(multiply(this.drawerWidth, -1), this.dragX), 0),
409 max(min(this.drawerWidth, this.dragX), 0)
410 );
411
412 private progress = cond(
413 // Check if the drawer width is available to avoid division by zero
414 eq(this.drawerWidth, 0),
415 0,
416 abs(divide(this.translateX, this.drawerWidth))
417 );
418
419 private handleGestureEvent = event([
420 {
421 nativeEvent: {
422 x: this.touchX,
423 translationX: this.gestureX,
424 velocityX: this.velocityX,
425 },
426 },
427 ]);
428
429 private handleGestureStateChange = event([
430 {
431 nativeEvent: {
432 state: (s: Animated.Value<number>) => set(this.gestureState, s),
433 },
434 },
435 ]);
436
437 private handleTapStateChange = event([
438 {
439 nativeEvent: {
440 oldState: (s: Animated.Value<number>) =>
441 cond(eq(s, State.ACTIVE), set(this.manuallyTriggerSpring, TRUE)),
442 },
443 },
444 ]);
445
446 private handleContainerLayout = (e: LayoutChangeEvent) =>
447 this.containerWidth.setValue(e.nativeEvent.layout.width);
448
449 private handleDrawerLayout = (e: LayoutChangeEvent) => {
450 this.drawerWidth.setValue(e.nativeEvent.layout.width);
451 this.toggleDrawer(this.props.open);
452
453 // Until layout is available, drawer is hidden with opacity: 0 by default
454 // Show it in the next frame when layout is available
455 // If we don't delay it until the next frame, there's a visible flicker
456 requestAnimationFrame(() =>
457 requestAnimationFrame(() => this.drawerOpacity.setValue(1))
458 );
459 };
460
461 private toggleDrawer = (open: boolean) => {
462 if (this.currentOpenValue !== open) {
463 this.nextIsOpen.setValue(open ? TRUE : FALSE);
464
465 // This value will also be set shortly after as changing this.nextIsOpen changes this.isOpen
466 // However, there's a race condition on Android, so we need to set a bit earlier
467 this.currentOpenValue = open;
468 }
469 };
470
471 private toggleStatusBar = (hidden: boolean) => {
472 const { hideStatusBar, statusBarAnimation } = this.props;
473
474 if (hideStatusBar && this.isStatusBarHidden !== hidden) {
475 this.isStatusBarHidden = hidden;
476 StatusBar.setHidden(hidden, statusBarAnimation);
477 }
478 };
479
480 render() {
481 const {
482 open,
483 gestureEnabled,
484 drawerPosition,
485 drawerType,
486 swipeEdgeWidth,
487 sceneContainerStyle,
488 drawerStyle,
489 overlayStyle,
490 onGestureRef,
491 renderDrawerContent,
492 renderSceneContent,
493 gestureHandlerProps,
494 } = this.props;
495
496 const right = drawerPosition === 'right';
497
498 const contentTranslateX = drawerType === 'front' ? 0 : this.translateX;
499 const drawerTranslateX =
500 drawerType === 'back'
501 ? I18nManager.isRTL
502 ? multiply(this.drawerWidth, DIRECTION_RIGHT)
503 : this.drawerWidth
504 : this.translateX;
505
506 const offset = I18nManager.isRTL ? '100%' : multiply(this.drawerWidth, -1);
507
508 // FIXME: Currently hitSlop is broken when on Android when drawer is on right
509 // https://github.com/kmagiera/react-native-gesture-handler/issues/569
510 const hitSlop = right
511 ? // Extend hitSlop to the side of the screen when drawer is closed
512 // This lets the user drag the drawer from the side of the screen
513 { right: 0, width: open ? undefined : swipeEdgeWidth }
514 : { left: 0, width: open ? undefined : swipeEdgeWidth };
515
516 return (
517 <DrawerProgressContext.Provider value={this.progress}>
518 <PanGestureHandler
519 ref={onGestureRef}
520 activeOffsetX={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
521 failOffsetY={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
522 onGestureEvent={this.handleGestureEvent}
523 onHandlerStateChange={this.handleGestureStateChange}
524 hitSlop={hitSlop}
525 enabled={gestureEnabled}
526 {...gestureHandlerProps}
527 >
528 <Animated.View
529 onLayout={this.handleContainerLayout}
530 style={styles.main}
531 >
532 <Animated.View
533 style={[
534 styles.content,
535 {
536 transform: [{ translateX: contentTranslateX }],
537 },
538 sceneContainerStyle as any,
539 ]}
540 importantForAccessibility={open ? 'no-hide-descendants' : 'yes'}
541 >
542 {renderSceneContent({ progress: this.progress })}
543 <TapGestureHandler
544 enabled={gestureEnabled}
545 onHandlerStateChange={this.handleTapStateChange}
546 >
547 <Animated.View
548 style={[
549 styles.overlay,
550 {
551 opacity: interpolate(this.progress, {
552 inputRange: [PROGRESS_EPSILON, 1],
553 outputRange: [0, 1],
554 }),
555 // We don't want the user to be able to press through the overlay when drawer is open
556 // One approach is to adjust the pointerEvents based on the progress
557 // But we can also send the overlay behind the screen, which works, and is much less code
558 zIndex: cond(
559 greaterThan(this.progress, PROGRESS_EPSILON),
560 0,
561 -1
562 ),
563 },
564 overlayStyle,
565 ]}
566 />
567 </TapGestureHandler>
568 </Animated.View>
569 <Animated.Code
570 exec={block([
571 onChange(this.manuallyTriggerSpring, [
572 cond(eq(this.manuallyTriggerSpring, TRUE), [
573 set(this.nextIsOpen, FALSE),
574 call([], () => (this.currentOpenValue = false)),
575 ]),
576 ]),
577 ])}
578 />
579 <Animated.View
580 accessibilityViewIsModal={open}
581 removeClippedSubviews={Platform.OS !== 'ios'}
582 onLayout={this.handleDrawerLayout}
583 style={[
584 styles.container,
585 right ? { right: offset } : { left: offset },
586 {
587 transform: [{ translateX: drawerTranslateX }],
588 opacity: this.drawerOpacity,
589 zIndex: drawerType === 'back' ? -1 : 0,
590 },
591 drawerStyle as any,
592 ]}
593 >
594 {renderDrawerContent({ progress: this.progress })}
595 </Animated.View>
596 </Animated.View>
597 </PanGestureHandler>
598 </DrawerProgressContext.Provider>
599 );
600 }
601}
602
603const styles = StyleSheet.create({
604 container: {
605 backgroundColor: 'white',
606 position: 'absolute',
607 top: 0,
608 bottom: 0,
609 width: '80%',
610 maxWidth: '100%',
611 },
612 overlay: {
613 ...StyleSheet.absoluteFillObject,
614 backgroundColor: 'rgba(0, 0, 0, 0.5)',
615 },
616 content: {
617 flex: 1,
618 },
619 main: {
620 flex: 1,
621 overflow: 'hidden',
622 },
623});