mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
331 lines
9.6 KiB
TypeScript
331 lines
9.6 KiB
TypeScript
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<number>;
|
|
}
|
|
|
|
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,
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
},
|
|
contentOuterWrapper: {
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
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,
|
|
flexShrink: 1,
|
|
},
|
|
menuWrapper: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
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,
|
|
|
|
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> = 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 = (
|
|
<AccessibleView
|
|
inert={!open}
|
|
style={styles.menuWrapper}
|
|
>
|
|
<AccessibleView
|
|
// Auto-focuses an empty view at the beginning of the sidemenu -- if we instead
|
|
// focus the container view, VoiceOver fails to focus to any components within
|
|
// the sidebar.
|
|
refocusCounter={!open ? 1 : undefined}
|
|
/>
|
|
|
|
{props.menu}
|
|
</AccessibleView>
|
|
);
|
|
|
|
const contentComponent = (
|
|
<AccessibleView
|
|
inert={open}
|
|
style={styles.contentWrapper}
|
|
>
|
|
<AccessibleView refocusCounter={open ? 1 : undefined} />
|
|
{props.children}
|
|
</AccessibleView>
|
|
);
|
|
const closeButtonOverlay = (open || animating) ? (
|
|
<Animated.View
|
|
style={styles.closeButtonOverlay}
|
|
>
|
|
<Pressable
|
|
aria-label={_('Close side menu')}
|
|
role='button'
|
|
onPress={onCloseButtonPress}
|
|
style={styles.overlayContent}
|
|
></Pressable>
|
|
</Animated.View>
|
|
) : null;
|
|
|
|
return (
|
|
<View
|
|
onLayout={onLayoutChange}
|
|
style={styles.mainContainer}
|
|
{...panResponder.panHandlers}
|
|
>
|
|
{menuComponent}
|
|
<Animated.View style={styles.contentOuterWrapper}>
|
|
{contentComponent}
|
|
{closeButtonOverlay}
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const SideMenu = connect((state: State) => {
|
|
return {
|
|
themeId: state.settings.theme,
|
|
isOpen: state.showSideMenu,
|
|
};
|
|
})(SideMenuComponent);
|
|
|
|
export default SideMenu;
|