diff --git a/.eslintignore b/.eslintignore index 4ae168a3df1..1cb1bcf37bf 100644 --- a/.eslintignore +++ b/.eslintignore @@ -759,6 +759,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js packages/app-mobile/utils/getPackageInfo.js packages/app-mobile/utils/getVersionInfoText.js +packages/app-mobile/utils/hooks/useReduceMotionEnabled.js packages/app-mobile/utils/image/fileToImage.web.js packages/app-mobile/utils/image/getImageDimensions.js packages/app-mobile/utils/image/resizeImage.js diff --git a/.gitignore b/.gitignore index 17a9cd028b7..8aac25028b0 100644 --- a/.gitignore +++ b/.gitignore @@ -736,6 +736,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js packages/app-mobile/utils/getPackageInfo.js packages/app-mobile/utils/getVersionInfoText.js +packages/app-mobile/utils/hooks/useReduceMotionEnabled.js packages/app-mobile/utils/image/fileToImage.web.js packages/app-mobile/utils/image/getImageDimensions.js packages/app-mobile/utils/image/resizeImage.js diff --git a/packages/app-mobile/components/SideMenu.ts b/packages/app-mobile/components/SideMenu.ts deleted file mode 100644 index 11f461befe0..00000000000 --- a/packages/app-mobile/components/SideMenu.ts +++ /dev/null @@ -1,23 +0,0 @@ -const { connect } = require('react-redux'); -const SideMenu_ = require('react-native-side-menu-updated').default; -import { Dimensions } from 'react-native'; -import { State } from '@joplin/lib/reducer'; - -class SideMenuComponent extends SideMenu_ { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public onLayoutChange(e: any) { - const { width, height } = e.nativeEvent.layout; - const openMenuOffsetPercentage = this.props.openMenuOffset / Dimensions.get('window').width; - const openMenuOffset = width * openMenuOffsetPercentage; - const hiddenMenuOffset = width * this.state.hiddenMenuOffsetPercentage; - this.setState({ width, height, openMenuOffset, hiddenMenuOffset }); - } -} - -const SideMenu = connect((state: State) => { - return { - isOpen: state.showSideMenu, - }; -})(SideMenuComponent); - -export default SideMenu; diff --git a/packages/app-mobile/components/SideMenu.tsx b/packages/app-mobile/components/SideMenu.tsx new file mode 100644 index 00000000000..4e8185049d1 --- /dev/null +++ b/packages/app-mobile/components/SideMenu.tsx @@ -0,0 +1,326 @@ +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; diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index e2435ffa34e..a40e6fb2e1c 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -19,14 +19,12 @@ import restoreItems from '@joplin/lib/services/trash/restoreItems'; import emptyTrash from '@joplin/lib/services/trash/emptyTrash'; import { ModelType } from '@joplin/lib/BaseModel'; import { DialogContext } from './DialogManager'; -import AccessibleView from './accessibility/AccessibleView'; const { TouchableRipple } = require('react-native-paper'); const { substrWithEllipsis } = require('@joplin/lib/string-utils'); interface Props { syncStarted: boolean; themeId: number; - sideMenuVisible: boolean; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied dispatch: Function; collapsedFolderIds: string[]; @@ -583,34 +581,22 @@ const SideMenuContentComponent = (props: Props) => { items = items.concat(folderItems); } - const isHidden = !props.sideMenuVisible; - const style = { flex: 1, borderRightWidth: 1, borderRightColor: theme.dividerColor, backgroundColor: theme.backgroundColor, - - // Have the UI reflect whether the View is hidden to the screen reader. - // This way, there will be visual feedback if isHidden is incorrect. - opacity: isHidden ? 0.5 : undefined, }; return ( - + {items} {renderBottomPanel()} - + ); }; @@ -624,9 +610,6 @@ export default connect((state: AppState) => { notesParentType: state.notesParentType, locale: state.settings.locale, themeId: state.settings.theme, - sideMenuVisible: state.showSideMenu, - // Don't do the opacity animation as it means re-rendering the list multiple times - // opacity: state.sideMenuOpenPercent, collapsedFolderIds: state.collapsedFolderIds, decryptionWorker: state.decryptionWorker, resourceFetcher: state.resourceFetcher, diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index b9aaf72e8ef..704f54f9651 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -66,7 +66,6 @@ "react-native-safe-area-context": "4.10.4", "react-native-securerandom": "1.0.1", "react-native-share": "10.2.1", - "react-native-side-menu-updated": "1.3.2", "react-native-sqlite-storage": "6.0.1", "react-native-url-polyfill": "2.0.0", "react-native-vector-icons": "10.1.0", diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 284cc18faad..f0cf843def8 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -29,7 +29,7 @@ import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive'; import initProfile from '@joplin/lib/services/profileConfig/initProfile'; const VersionInfo = require('react-native-version-info').default; const { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } = require('react-native'); -import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, AccessibilityInfo, ActivityIndicator } from 'react-native'; +import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native'; import getResponsiveValue from './components/getResponsiveValue'; import NetInfo from '@react-native-community/netinfo'; const DropdownAlert = require('react-native-dropdownalert').default; @@ -66,7 +66,7 @@ const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js' import EncryptionConfigScreen from './components/screens/encryption-config'; const { DropboxLoginScreen } = require('./components/screens/dropbox-login.js'); import { MenuProvider } from 'react-native-popup-menu'; -import SideMenu from './components/SideMenu'; +import SideMenu, { SideMenuPosition } from './components/SideMenu'; import SideMenuContent from './components/side-menu-content'; const { SideMenuContentNote } = require('./components/side-menu-content-note.js'); import { reg } from '@joplin/lib/registry'; @@ -137,8 +137,6 @@ import lockToSingleInstance from './utils/lockToSingleInstance'; import { AppState } from './utils/types'; import { getDisplayParentId } from '@joplin/lib/services/trash'; -type SideMenuPosition = 'left' | 'right'; - const logger = Logger.create('root'); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -393,12 +391,6 @@ const appReducer = (state = appDefaultState, action: any) => { newState.showSideMenu = false; break; - case 'SIDE_MENU_OPEN_PERCENT': - - newState = { ...state }; - newState.sideMenuOpenPercent = action.value; - break; - case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE': newState = { ...state }; newState.showPanelsDialog = action.visible; @@ -1188,9 +1180,6 @@ class AppComponent extends React.Component { this.props.dispatch({ type: isOpen ? 'SIDE_MENU_OPEN' : 'SIDE_MENU_CLOSE', }); - AccessibilityInfo.announceForAccessibility( - isOpen ? _('Side menu opened') : _('Side menu closed'), - ); } private getSideMenuWidth = () => { @@ -1223,12 +1212,12 @@ class AppComponent extends React.Component { const theme: Theme = themeStyle(this.props.themeId); let sideMenuContent: ReactNode = null; - let menuPosition: SideMenuPosition = 'left'; + let menuPosition = SideMenuPosition.Left; let disableSideMenuGestures = this.props.disableSideMenuGestures; if (this.props.routeName === 'Note') { sideMenuContent = ; - menuPosition = 'right'; + menuPosition = SideMenuPosition.Right; } else if (this.props.routeName === 'Config') { disableSideMenuGestures = true; } else { @@ -1283,12 +1272,6 @@ class AppComponent extends React.Component { menuPosition={menuPosition} onChange={(isOpen: boolean) => this.sideMenu_change(isOpen)} disableGestures={disableSideMenuGestures} - onSliding={(percent: number) => { - this.props.dispatch({ - type: 'SIDE_MENU_OPEN_PERCENT', - value: percent, - }); - }} > diff --git a/packages/app-mobile/utils/appDefaultState.ts b/packages/app-mobile/utils/appDefaultState.ts index ef3bad2ef6b..0eaf1c44e73 100644 --- a/packages/app-mobile/utils/appDefaultState.ts +++ b/packages/app-mobile/utils/appDefaultState.ts @@ -11,7 +11,6 @@ export const DEFAULT_ROUTE = { const appDefaultState: AppState = { smartFilterId: undefined, ...defaultState, - sideMenuOpenPercent: 0, route: DEFAULT_ROUTE, noteSelectionEnabled: false, noteSideMenuOptions: null, diff --git a/packages/app-mobile/utils/hooks/useReduceMotionEnabled.ts b/packages/app-mobile/utils/hooks/useReduceMotionEnabled.ts new file mode 100644 index 00000000000..e018c1e5877 --- /dev/null +++ b/packages/app-mobile/utils/hooks/useReduceMotionEnabled.ts @@ -0,0 +1,19 @@ +import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +const useReduceMotionEnabled = () => { + const [reduceMotionEnabled, setReduceMotionEnabled] = useState(false); + useEffect(() => { + AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => { + setReduceMotionEnabled(enabled); + }); + }, []); + useAsyncEffect(async () => { + setReduceMotionEnabled(await AccessibilityInfo.isReduceMotionEnabled()); + }, []); + + return reduceMotionEnabled; +}; + +export default useReduceMotionEnabled; diff --git a/packages/app-mobile/utils/types.ts b/packages/app-mobile/utils/types.ts index ab7a2a0b7b8..33b2b711ac3 100644 --- a/packages/app-mobile/utils/types.ts +++ b/packages/app-mobile/utils/types.ts @@ -1,7 +1,6 @@ import { State } from '@joplin/lib/reducer'; export interface AppState extends State { - sideMenuOpenPercent: number; showPanelsDialog: boolean; isOnMobileData: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied diff --git a/yarn.lock b/yarn.lock index 8d7cb6b703e..3a3ea8947d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7535,7 +7535,6 @@ __metadata: react-native-safe-area-context: 4.10.4 react-native-securerandom: 1.0.1 react-native-share: 10.2.1 - react-native-side-menu-updated: 1.3.2 react-native-sqlite-storage: 6.0.1 react-native-url-polyfill: 2.0.0 react-native-vector-icons: 10.1.0 @@ -36465,7 +36464,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:15.8.1, prop-types@npm:^15.5.10, prop-types@npm:^15.8.1": +"prop-types@npm:15.8.1, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -37402,15 +37401,6 @@ __metadata: languageName: node linkType: hard -"react-native-side-menu-updated@npm:1.3.2": - version: 1.3.2 - resolution: "react-native-side-menu-updated@npm:1.3.2" - dependencies: - prop-types: ^15.5.10 - checksum: 5d7ae7d2b372c80d9f7a3472f945daa6c11b43f00193ebec92fdb40ee853e86f522686736aa6a510a4fb09479c8eb7e4f14b53ad117d7e23749cd58c3c9d6cb7 - languageName: node - linkType: hard - "react-native-sqlite-storage@npm:6.0.1": version: 6.0.1 resolution: "react-native-sqlite-storage@npm:6.0.1"