import * as React from 'react'; import { connect } from 'react-redux'; import { AccessibilityInfo, Animated, Dimensions, Easing, I18nManager, LayoutChangeEvent, PanResponder, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import { State } from '@joplin/lib/reducer'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import AccessibleView from './accessibility/AccessibleView'; import { _ } from '@joplin/lib/locale'; import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled'; import { themeStyle } from './global-style'; export enum SideMenuPosition { Left = 'left', Right = 'right', } export type OnChangeCallback = (isOpen: boolean)=> void; interface Props { themeId: number; isOpen: boolean; menu: React.ReactNode; children: React.ReactNode|React.ReactNode[]; edgeHitWidth: number; toleranceX: number; toleranceY: number; openMenuOffset: number; menuPosition: SideMenuPosition; onChange: OnChangeCallback; disableGestures: boolean; } interface UseStylesProps { themeId: number; isLeftMenu: boolean; menuWidth: number; menuOpenFraction: Animated.AnimatedInterpolation; } const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => { const { height: windowHeight, width: windowWidth } = useWindowDimensions(); return useMemo(() => { const theme = themeStyle(themeId); return StyleSheet.create({ mainContainer: { display: 'flex', alignContent: 'stretch', height: windowHeight, }, contentOuterWrapper: { width: windowWidth, height: windowHeight, transform: [{ translateX: menuOpenFraction.interpolate({ inputRange: [0, 1], outputRange: [0, isLeftMenu ? menuWidth : -menuWidth], }), // The RN Animation docs suggests setting "perspective" while setting other transform styles: // https://reactnative.dev/docs/animations#bear-in-mind }, { perspective: 1000 }], }, contentWrapper: { display: 'flex', flexDirection: 'column', flexGrow: 1, }, menuWrapper: { position: 'absolute', height: windowHeight, width: menuWidth, // In React Native, RTL replaces `left` with `right` and `right` with `left`. // As such, we need to reverse the normal direction in RTL mode. ...(isLeftMenu === !I18nManager.isRTL ? { left: 0, } : { right: 0, }), }, closeButtonOverlay: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, zIndex: 1, width: windowWidth, height: windowHeight, opacity: menuOpenFraction.interpolate({ inputRange: [0, 1], outputRange: [0, 0.1], extrapolate: 'clamp', }), backgroundColor: theme.colorFaded, display: 'flex', alignContent: 'stretch', }, overlayContent: { height: windowHeight, width: windowWidth, }, }); }, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction]); }; interface UseAnimationsProps { menuWidth: number; isLeftMenu: boolean; open: boolean; } const useAnimations = ({ menuWidth, isLeftMenu, open }: UseAnimationsProps) => { const [animating, setIsAnimating] = useState(false); const menuDragOffset = useMemo(() => new Animated.Value(0), []); const basePositioningFraction = useMemo(() => new Animated.Value(0), []); const maximumDragOffsetValue = useMemo(() => new Animated.Value(1), []); // Update the value in a useEffect to prevent delays in applying the animation caused by // re-renders. useEffect(() => { // In a right-side menu, the drag offset increases while the menu is closing. // It needs to be inverted in that case: // || 1: Prevents division by zero maximumDragOffsetValue.setValue((menuWidth || 1) * (isLeftMenu ? 1 : -1)); }, [menuWidth, isLeftMenu, maximumDragOffsetValue]); const menuOpenFraction = useMemo(() => { const animatedDragFraction = Animated.divide(menuDragOffset, maximumDragOffsetValue); return Animated.add(basePositioningFraction, animatedDragFraction); }, [menuDragOffset, basePositioningFraction, maximumDragOffsetValue]); const reduceMotionEnabled = useReduceMotionEnabled(); const reduceMotionEnabledRef = useRef(false); reduceMotionEnabledRef.current = reduceMotionEnabled; const updateMenuPosition = useCallback(() => { const baseAnimationProps = { easing: Easing.elastic(0.5), duration: reduceMotionEnabledRef.current ? 0 : 200, useNativeDriver: true, }; setIsAnimating(true); const animation = Animated.parallel([ Animated.timing(basePositioningFraction, { toValue: open ? 1 : 0, ...baseAnimationProps }), Animated.timing(menuDragOffset, { toValue: 0, ...baseAnimationProps }), ]); animation.start((result) => { if (result.finished) { setIsAnimating(false); } }); }, [open, menuDragOffset, basePositioningFraction]); useEffect(() => { updateMenuPosition(); }, [updateMenuPosition]); return { setIsAnimating, animating, updateMenuPosition, menuOpenFraction, menuDragOffset }; }; const SideMenuComponent: React.FC = props => { const [open, setIsOpen] = useState(false); useEffect(() => { setIsOpen(props.isOpen); }, [props.isOpen]); const [menuWidth, setMenuWidth] = useState(0); const [contentWidth, setContentWidth] = useState(0); // In right-to-left layout, swap left and right to be consistent with other parts of // the app's layout. const isLeftMenu = props.menuPosition === (I18nManager.isRTL ? SideMenuPosition.Right : SideMenuPosition.Left); const onLayoutChange = useCallback((e: LayoutChangeEvent) => { const { width } = e.nativeEvent.layout; const openMenuOffsetPercentage = props.openMenuOffset / Dimensions.get('window').width; const menuWidth = Math.floor(width * openMenuOffsetPercentage); setContentWidth(width); setMenuWidth(menuWidth); }, [props.openMenuOffset]); const { animating, setIsAnimating, menuDragOffset, updateMenuPosition, menuOpenFraction } = useAnimations({ isLeftMenu, menuWidth, open, }); const panResponder = useMemo(() => { return PanResponder.create({ onMoveShouldSetPanResponderCapture: (_event, gestureState) => { if (props.disableGestures) { return false; } let startX; let dx; const dy = gestureState.dy; // Untransformed start position of the gesture -- moveX is the current position of // the pointer. Subtracting dx gives us the original start position. const gestureStartScreenX = gestureState.moveX - gestureState.dx; // Transform x, dx such that they are relative to the target screen edge -- this simplifies later // math. if (isLeftMenu) { startX = gestureStartScreenX; dx = gestureState.dx; } else { startX = contentWidth - gestureStartScreenX; dx = -gestureState.dx; } const motionWithinToleranceY = Math.abs(dy) <= props.toleranceY; let startWithinTolerance, motionWithinToleranceX; if (open) { startWithinTolerance = startX >= menuWidth - props.edgeHitWidth; motionWithinToleranceX = dx <= -props.toleranceX; } else { startWithinTolerance = startX <= props.edgeHitWidth; motionWithinToleranceX = dx >= props.toleranceX; } return startWithinTolerance && motionWithinToleranceX && motionWithinToleranceY; }, onPanResponderGrant: () => { setIsAnimating(true); }, onPanResponderMove: Animated.event([ null, // Updates menuDragOffset with the .dx property of the second argument: { dx: menuDragOffset }, ], { useNativeDriver: false }), onPanResponderEnd: (_event, gestureState) => { const newOpen = (gestureState.dx > 0) === isLeftMenu; if (newOpen === open) { updateMenuPosition(); } else { setIsOpen(newOpen); } }, }); }, [isLeftMenu, menuDragOffset, menuWidth, props.toleranceX, props.toleranceY, contentWidth, open, props.disableGestures, props.edgeHitWidth, updateMenuPosition, setIsAnimating]); const onChangeRef = useRef(props.onChange); onChangeRef.current = props.onChange; useEffect(() => { onChangeRef.current(open); AccessibilityInfo.announceForAccessibility( open ? _('Side menu opened') : _('Side menu closed'), ); }, [open]); const onCloseButtonPress = useCallback(() => { setIsOpen(false); // Set isAnimating as soon as possible to avoid components disappearing, then reappearing. setIsAnimating(true); }, [setIsAnimating]); const styles = useStyles({ themeId: props.themeId, menuOpenFraction, menuWidth, isLeftMenu }); const menuComponent = ( {props.menu} ); const contentComponent = ( {props.children} ); const closeButtonOverlay = (open || animating) ? ( ) : null; return ( {menuComponent} {contentComponent} {closeButtonOverlay} ); }; const SideMenu = connect((state: State) => { return { themeId: state.settings.theme, isOpen: state.showSideMenu, }; })(SideMenuComponent); export default SideMenu;