diff --git a/packages/mobile/src/screens/app-drawer-screen/AppDrawerScreen.tsx b/packages/mobile/src/screens/app-drawer-screen/AppDrawerScreen.tsx index 14eb88ff312..724f79c0fe3 100644 --- a/packages/mobile/src/screens/app-drawer-screen/AppDrawerScreen.tsx +++ b/packages/mobile/src/screens/app-drawer-screen/AppDrawerScreen.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { DrawerContentComponentProps } from '@react-navigation/drawer' import { createDrawerNavigator } from '@react-navigation/drawer' import { useNavigation } from '@react-navigation/native' -import { Dimensions, Platform, View, StyleSheet } from 'react-native' +import { Platform, View, StyleSheet } from 'react-native' import { IconAudiusLogoHorizontal } from '@audius/harmony-native' import { AudioPlayer } from 'app/components/audio/AudioPlayer' @@ -15,7 +15,7 @@ import { AppScreen } from '../app-screen' import { AppDrawerContextProvider } from './AppDrawerContext' import { LeftNavDrawer } from './left-nav-drawer' -const SCREEN_WIDTH = Dimensions.get('window').width +const DRAWER_SWIPE_EDGE_WIDTH = 40 const Drawer = createDrawerNavigator() @@ -66,13 +66,14 @@ export const AppDrawerScreen = memo(() => { // Drawer swipe-to-open is enabled only when at the tab stack's root, so // the right-swipe gesture inside a nested stack falls through to the native - // stack's fullScreenSwipe back behavior. swipeEdgeWidth stays at the full - // screen width so opening the drawer from the root doesn't require a swipe - // from the screen edge. + // stack's fullScreenSwipe back behavior. The navigator's pan (fixed 5pt + // thresholds) is limited to the left edge so it can't take vertical scrolls; + // mid-screen opening uses `useOpenDrawerGesture`. swipeEdgeWidth only applies + // while closed, so swipe-to-close stays full-width. const drawerScreenOptions = useMemo( () => ({ headerShown: false, - swipeEdgeWidth: SCREEN_WIDTH, + swipeEdgeWidth: DRAWER_SWIPE_EDGE_WIDTH, drawerType: 'slide' as const, drawerStyle: { width: '75%' as const }, swipeEnabled: diff --git a/packages/mobile/src/screens/app-drawer-screen/useOpenDrawerGesture.ts b/packages/mobile/src/screens/app-drawer-screen/useOpenDrawerGesture.ts new file mode 100644 index 00000000000..cce7ea9a84f --- /dev/null +++ b/packages/mobile/src/screens/app-drawer-screen/useOpenDrawerGesture.ts @@ -0,0 +1,34 @@ +import { useContext, useMemo } from 'react' + +import { Gesture } from 'react-native-gesture-handler' + +import { AppDrawerContext } from './AppDrawerContext' + +// Rightward travel needed before the pan claims the touch. +const OPEN_DRAWER_ACTIVE_OFFSET_X = 20 +// Fail on 12pt of vertical travel, so only drags within ~30 degrees of +// horizontal open the drawer. +const OPEN_DRAWER_FAIL_OFFSET_Y = 12 + +/** + * Rightward, mostly horizontal pan that opens the left nav drawer from + * mid-screen. The drawer navigator's own pan has fixed 5pt thresholds, so it + * is limited to the screen edge. + */ +export const useOpenDrawerGesture = (enabled: boolean) => { + const { drawerHelpers } = useContext(AppDrawerContext) + + return useMemo( + () => + Gesture.Pan() + .enabled(enabled) + .activeOffsetX(OPEN_DRAWER_ACTIVE_OFFSET_X) + .failOffsetX(-OPEN_DRAWER_ACTIVE_OFFSET_X) + .failOffsetY([-OPEN_DRAWER_FAIL_OFFSET_Y, OPEN_DRAWER_FAIL_OFFSET_Y]) + .runOnJS(true) + .onStart(() => { + drawerHelpers?.openDrawer() + }), + [enabled, drawerHelpers] + ) +} diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx index 3398ed19f72..3d3fb534c66 100644 --- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx @@ -19,12 +19,15 @@ import type { import type { EventArg, NavigationState } from '@react-navigation/native' import { useIsFocused } from '@react-navigation/native' import type { createNativeStackNavigator } from '@react-navigation/native-stack' +import { StyleSheet, View } from 'react-native' +import { GestureDetector } from 'react-native-gesture-handler' import { FilterButtonScreen } from '@audius/harmony-native' import type { FilterButtonScreenParams } from '@audius/harmony-native' import { useDrawer } from 'app/hooks/useDrawer' import { setLastNavAction } from 'app/hooks/useNavigation' import { AppDrawerContext } from 'app/screens/app-drawer-screen' +import { useOpenDrawerGesture } from 'app/screens/app-drawer-screen/useOpenDrawerGesture' import { SetAppTabNavigationContext } from 'app/screens/app-screen/AppTabNavigationProvider' import type { AppTabNavigation } from 'app/screens/app-screen/AppTabNavigationProvider' import { AudioScreen } from 'app/screens/audio-screen' @@ -187,12 +190,13 @@ type AppTabScreenProps = { */ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { const screenOptions = useAppScreenOptions() - const { drawerNavigation, setIsAtStackRoot } = useContext(AppDrawerContext) + const { drawerNavigation, setIsAtStackRoot, gesturesDisabled } = + useContext(AppDrawerContext) const { isOpen: isNowPlayingDrawerOpen } = useDrawer('NowPlaying') const { setNavigation } = useContext(SetAppTabNavigationContext) const isFocused = useIsFocused() const isAtStackRootRef = useRef(true) - const [isAtStackRoot, setIsAtStackRootState] = useState(true) + const [isAtStackRoot, setIsAtStackRootLocal] = useState(true) const applyDrawerSwipe = useCallback( (isAtRoot: boolean) => { @@ -213,7 +217,7 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { if (event?.data?.state?.type !== 'stack') return const isAtRoot = event.data.state.routes.length === 1 isAtStackRootRef.current = isAtRoot - setIsAtStackRootState(isAtRoot) + setIsAtStackRootLocal(isAtRoot) if (isFocused) applyDrawerSwipe(isAtRoot) }, [isFocused, applyDrawerSwipe] @@ -246,139 +250,166 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { [handleChangeState, handleTransitionEnd, setNavigation] ) + // Mid-screen swipe-to-open on the tab's root screen. Off when a screen handles + // horizontal gestures itself (gesturesDisabled, e.g. Feed) or when a right + // swipe means back. + const openDrawerGesture = useOpenDrawerGesture( + isFocused && isAtStackRoot && !gesturesDisabled && !isNowPlayingDrawerOpen + ) + return ( // Publishes the floating header heights and scroll signal to this tab's // screens. {/* Hands the scroll signal to the tab bar, which renders outside this provider. */} - - {baseScreen(Stack)} - - - - - - - - - - - - - - + + + + {baseScreen(Stack)} + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - // @ts-ignore hard to correctly type navigation params (PAY-1141) - params?.chatId - } - options={{ ...screenOptions, fullScreenGestureEnabled: false }} - /> - - + + + + + + + // @ts-ignore hard to correctly type navigation params (PAY-1141) + params?.chatId + } + options={{ ...screenOptions, fullScreenGestureEnabled: false }} + /> + + + + ) } + +const styles = StyleSheet.create({ + root: { flex: 1 } +}) diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx index 7674ae54698..477aaece0c1 100644 --- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx @@ -29,6 +29,7 @@ import { TrackLineup } from 'app/components/lineup/TrackLineup' import { SuggestedFollows } from 'app/components/suggested-follows' import { useDrawer } from 'app/hooks/useDrawer' import { AppDrawerContext } from 'app/screens/app-drawer-screen' +import { useOpenDrawerGesture } from 'app/screens/app-drawer-screen/useOpenDrawerGesture' import { FloatingSubHeader } from 'app/screens/app-screen/FloatingSubHeader' import { useGlassHeaderInset, @@ -90,14 +91,14 @@ export const FeedScreen = () => { [feedArgs] ) - const { drawerHelpers, setGesturesDisabled } = useContext(AppDrawerContext) + const { setGesturesDisabled } = useContext(AppDrawerContext) const { isOpen: isNowPlayingDrawerOpen } = useDrawer('NowPlaying') const drawerStatus = useDrawerStatus() - // The drawer's own root-level swipe-to-open (swipeEdgeWidth = full screen) - // competes with the pager here: with both active, a right-swipe on Latest - // opens the drawer instead of paging back to For You, and the two openers - // arbitrate unpredictably with the track tiles' tap gesture. While the feed + // The drawer navigator's edge swipe-to-open competes with the pager here: + // with both active, a right-swipe on Latest opens the drawer instead of + // paging back to For You, and the two openers arbitrate unpredictably with + // the track tiles' tap gesture. While the feed // is focused and the drawer is closed, turn the native drawer swipe off so // the pager owns paging and the gesture below is the single drawer-opener // (For You only). Re-enable once it's open so it can still be swiped closed, @@ -112,21 +113,12 @@ export const FeedScreen = () => { // The For You page is the leftmost pager page, so a right-swipe there has // no page to fall back to — PagerView would otherwise swallow it, leaving // the left nav drawer unreachable by gesture (only the header avatar would - // open it). This gesture re-enables swipe-to-open on For You: it activates - // only on a rightward drag (`activeOffsetX`) and bails on a leftward drag - // (`failOffsetX`) so swiping to Latest still reaches the pager untouched. + // open it). The shared drawer-opener only claims a clearly horizontal + // rightward drag, so swiping to Latest still reaches the pager untouched and + // a vertical scroll with sideways drift stays with the lineup. // Disabled on Latest, where right-swipe legitimately pages back to For You. - const openDrawerGesture = useMemo( - () => - Gesture.Pan() - .enabled(isForYou && !isNowPlayingDrawerOpen) - .activeOffsetX(20) - .failOffsetX(-20) - .runOnJS(true) - .onStart(() => { - drawerHelpers?.openDrawer() - }), - [isForYou, isNowPlayingDrawerOpen, drawerHelpers] + const openDrawerGesture = useOpenDrawerGesture( + isForYou && !isNowPlayingDrawerOpen ) // Run the pan simultaneously with the pager's own native gesture. Without diff --git a/patches/react-native-screens+4.18.0.patch b/patches/react-native-screens+4.18.0.patch new file mode 100644 index 00000000000..2a0055dddb2 --- /dev/null +++ b/patches/react-native-screens+4.18.0.patch @@ -0,0 +1,73 @@ +diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm +index 51f0218..263f558 100644 +--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm ++++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm +@@ -915,6 +915,12 @@ RNS_IGNORE_SUPER_CALL_END + // with native interactiveContentPopGestureRecognizer, so we have to fallback to the old implementation. + // In this case, the old one should behave as close as the new native one, having only the difference + // in animation, and without any customization that is exclusive for it (e.g. gestureResponseDistance). ++ // AUDIUS PATCH: full-screen swipe-to-pop must be a committed horizontal drag, ++ // otherwise a slightly-diagonal vertical scroll gets hijacked as a back gesture. ++ if (![self rnsAudius_isCommittedDismissPan:gestureRecognizer topScreen:topScreen]) { ++ return NO; ++ } ++ + if (@available(iOS 26, *)) { + if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) { + _isFullWidthSwipingWithPanGesture = YES; +@@ -1117,6 +1123,55 @@ RNS_IGNORE_SUPER_CALL_END + return _controller; + } + ++// AUDIUS PATCH ----------------------------------------------------------------- ++// Require the full-screen pop pan to be mostly horizontal (along >= 2x across, ++// >= 5pt) so a diagonal vertical scroll isn't taken as swipe-to-pop. Keep the ++// minimum below UIPanGestureRecognizer's ~10pt activation, since ++// gestureRecognizerShouldBegin: runs once at that point. ++static const CGFloat RNSAudiusDismissMinTranslation = 5.0; ++static const CGFloat RNSAudiusDismissDominanceRatio = 2.0; ++ ++- (BOOL)rnsAudius_isCommittedDismissPan:(UIGestureRecognizer *)gestureRecognizer ++ topScreen:(RNSScreenView *)topScreen ++{ ++ if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) { ++ return YES; ++ } ++ ++ UIPanGestureRecognizer *pan = (UIPanGestureRecognizer *)gestureRecognizer; ++ CGPoint translation = [pan translationInView:pan.view]; ++ ++ CGFloat alongAxis; ++ CGFloat acrossAxis; ++ ++ if (topScreen.swipeDirection == RNSScreenSwipeDirectionVertical) { ++ // Vertically-dismissed screens (e.g. modals): down is the dismiss direction. ++ alongAxis = translation.y; ++ acrossAxis = translation.x; ++ } else { ++ alongAxis = translation.x; ++ acrossAxis = translation.y; ++ ++ // Mirror the direction handling in `handleSwipe:` so RTL layouts and ++ // slide-from-left screens dismiss toward their own trailing edge. ++ if (_controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft) { ++ alongAxis = -alongAxis; ++ } ++ if (topScreen.stackAnimation == RNSScreenStackAnimationSlideFromLeft) { ++ alongAxis = -alongAxis; ++ } ++ } ++ ++ // Must be moving in the dismiss direction. ++ if (alongAxis < RNSAudiusDismissMinTranslation) { ++ return NO; ++ } ++ ++ // Must be mostly along the dismiss axis. ++ return fabs(alongAxis) >= fabs(acrossAxis) * RNSAudiusDismissDominanceRatio; ++} ++// END AUDIUS PATCH ------------------------------------------------------------- ++ + - (BOOL)isInGestureResponseDistance:(UIGestureRecognizer *)gestureRecognizer topScreen:(RNSScreenView *)topScreen + { + NSDictionary *gestureResponseDistanceValues = topScreen.gestureResponseDistance;