Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ require File.join(File.dirname(`node --print "require.resolve('expo/package.json
# This value is used by $RNMapboxMaps
$RNMapboxMapsImpl = 'mapbox'
$VCDisableFrameProcessors = true
# Compiles react-native-screens' experimental (gamma) iOS code, which ScrollViewMarker needs.
# Without it, rendering ScrollViewMarker crashes on iOS.
ENV['RNS_GAMMA_ENABLED'] = '1'

def node_require(script)
# Resolve script with node to allow for hoisting
Expand Down
4 changes: 2 additions & 2 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4116,7 +4116,7 @@ SPEC CHECKSUMS:
RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c
RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73
RNReanimated: e75c988a6527ec651ea2957b93891ddb64d530d4
RNScreens: 8708989f700bdc3dc5abc3c77aa3b5f3e73ce2b2
RNScreens: 433b8c24dd1dfeaa3b61f72d55052cb64fd19c5f
RNSentry: 3dcc26572ed12d8ee7edbe80d198dda70ac10d4c
RNShare: eb378c57de30a83b0253eed74762ee99249f47d7
RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c
Expand All @@ -4134,6 +4134,6 @@ SPEC CHECKSUMS:
VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe
Yoga: c30c859b7e9b75e547b600b486fe33165f60de00

PODFILE CHECKSUM: 64b30fca02abc208520075c2e1275b24533fa805
PODFILE CHECKSUM: c937d1b7b774ce019c0c848e4ad1e850f08c0e90

COCOAPODS: 1.16.2
7 changes: 7 additions & 0 deletions patches/react-native-screens/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,10 @@
- Upstream PR/issue: TBD
- E/App issue: [#104226](https://github.com/Expensify/App/issues/104226)
- PR introducing patch: [#104238](https://github.com/Expensify/App/pull/104238)

### [react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch](react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch)

- Reason: On iOS, a second tap on a native tab scrolls the tab's content to the top. When the tab's root is a native stack, as with the Inbox tab's split navigator, `RNSScreenStack` handles that tap and picks the scroll view by following each view's first native subview. On the Inbox the first one it reaches is the horizontal tab selector above the list. That selector is already at the top, so nothing scrolls. A `ScrollViewMarker` around the list registers the list's scroll view with the tabs screen, but the stack never reads it. The patch makes the stack scroll the marked scroll view while it shows a single screen, and fall back to the first-subview search otherwise, since the marker can sit on any screen of the stack. The marker only exists on iOS when `RNS_GAMMA_ENABLED=1` is set at `pod install`, which both Podfiles do.
- Upstream PR/issue: [#4985](https://github.com/software-mansion/react-native-screens/pull/4985)
- E/App issue: https://github.com/Expensify/App/issues/104284
- PR introducing patch: [#104368](https://github.com/Expensify/App/pull/104368)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
@@ -143,8 +143,12 @@ namespace react = facebook::react;
tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionPopToRootSpecialEffect) {
return [[self popToRootViewControllerAnimated:true] count] > 0;
} else if (tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect) {
- UIScrollView *scrollView =
- [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]];
+ // A ScrollViewMarker registers its scroll view with the tabs screen, not with this stack, so it can be on any
+ // screen of the stack. It is used only while the stack shows a single screen.
+ UIScrollView *markedScrollView =
+ [[self viewControllers] count] == 1 ? [tabScreenController.tabScreenComponentView cachedContentScrollView] : nil;
+ UIScrollView *scrollView = markedScrollView
+ ?: [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]];
return [scrollView rnscreens_scrollToTop];
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
index e6ea55b..4d577fe 100644
index b087433de3b..f729d3ecbf1 100644
--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac
Expand Down Expand Up @@ -39,7 +39,29 @@ index e6ea55b..4d577fe 100644
provenance: 0
}
});
@@ -189,17 +201,17 @@ export function NativeBottomTabView({
@@ -88,7 +100,9 @@ export function NativeBottomTabView({
target: route.key
});
}
- if (actionOrigin === 'programmatic-js' || focusedRouteKey === route.key) {
+ // While a hidden route is focused, native keeps the tab underneath it selected, so native reporting that tab is
+ // not a request to leave the hidden route.
+ if (actionOrigin === 'programmatic-js' || nativeSelectedRouteKey === route.key) {
dispatch({
type: 'CONFIRM_STATE',
confirmed
@@ -114,7 +128,9 @@ export function NativeBottomTabView({
console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`);
return;
}
- if (focusedRouteKey === route.key) {
+ // Android re-sends an unchanged request on a re-render and rejects it as repeated, which reports the tab under a
+ // focused hidden route. Following it would close the hidden route.
+ if (nativeSelectedRouteKey === route.key) {
dispatch({
type: 'CONFIRM_STATE',
confirmed
@@ -189,17 +205,17 @@ export function NativeBottomTabView({
const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior;
const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none';
const bottomAccessory = currentOptions.bottomAccessory;
Expand All @@ -61,7 +83,7 @@ index e6ea55b..4d577fe 100644
colorScheme: dark ? 'dark' : 'light',
nativeContainerStyle: {
backgroundColor: colors.background
@@ -213,6 +225,9 @@ export function NativeBottomTabView({
@@ -213,6 +229,9 @@ export function NativeBottomTabView({
tabBarTintColor: activeTintColor
},
children: state.routes.map((route, index) => {
Expand All @@ -71,23 +93,23 @@ index e6ea55b..4d577fe 100644
const {
options,
render,
@@ -224,6 +239,7 @@ export function NativeBottomTabView({
@@ -224,6 +243,7 @@ export function NativeBottomTabView({
title,
lazy = false,
tabBarLabel,
+ tabBarAccessibilityLabel,
tabBarSelectionEnabled,
tabBarBadgeStyle,
tabBarIcon,
@@ -290,6 +306,7 @@ export function NativeBottomTabView({
@@ -290,6 +310,7 @@ export function NativeBottomTabView({
preventNativeSelection: tabBarSelectionEnabled === false,
badgeValue: tabBarBadge?.toString(),
title: tabTitle,
+ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel,
nativeContainerStyle: {
backgroundColor: colors.background
},
@@ -369,9 +386,40 @@ export function NativeBottomTabView({
@@ -369,9 +390,40 @@ export function NativeBottomTabView({
})
}, route.key);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
index 5a152a3d182..d2d69feda98 100644
index f729d3ecbf1..8f349432b40 100644
--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
@@ -39,6 +39,10 @@ export function NativeBottomTabView({
Expand Down Expand Up @@ -31,7 +31,7 @@ index 5a152a3d182..d2d69feda98 100644
if (!route) {
console.error(`Received 'tabSelected' for route that doesn't exist: ${selectedScreenKey}`);
return;
@@ -121,7 +125,7 @@ export function NativeBottomTabView({
@@ -123,7 +127,7 @@ export function NativeBottomTabView({
routeKey: selectedScreenKey,
provenance
};
Expand All @@ -40,7 +40,7 @@ index 5a152a3d182..d2d69feda98 100644
if (!route) {
console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`);
return;
@@ -150,7 +154,7 @@ export function NativeBottomTabView({
@@ -154,7 +158,7 @@ export function NativeBottomTabView({
});
navigation.emit({
type: 'tabPress',
Expand All @@ -49,7 +49,7 @@ index 5a152a3d182..d2d69feda98 100644
});
};
const currentOptions = descriptors[state.routes[state.index].key]?.options;
@@ -204,7 +208,7 @@ export function NativeBottomTabView({
@@ -208,7 +212,7 @@ export function NativeBottomTabView({
return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, {
children: [/*#__PURE__*/_jsx(Tabs.Host, {
navStateRequest: {
Expand All @@ -58,7 +58,7 @@ index 5a152a3d182..d2d69feda98 100644
baseProvenance: nativeState.confirmed.provenance
},
rejectStaleNavStateUpdates: true,
@@ -302,7 +306,7 @@ export function NativeBottomTabView({
@@ -306,7 +310,7 @@ export function NativeBottomTabView({
closing: true,
route
}),
Expand All @@ -67,15 +67,15 @@ index 5a152a3d182..d2d69feda98 100644
preventNativeSelection: tabBarSelectionEnabled === false,
badgeValue: tabBarBadge?.toString(),
title: tabTitle,
@@ -370,6 +374,7 @@ export function NativeBottomTabView({
@@ -374,6 +378,7 @@ export function NativeBottomTabView({
},
overrideScrollViewContentInsetAdjustmentBehavior
},
+ // Keyed by route key, so a re-keyed route still remounts its content inside the same native tab.
Comment thread
war-in marked this conversation as resolved.
children: /*#__PURE__*/_jsx(Deferred, {
lazy: lazy,
visible: isFocused || isPreloaded,
@@ -383,8 +388,8 @@ export function NativeBottomTabView({
@@ -387,8 +392,8 @@ export function NativeBottomTabView({
children: render()
})
})
Expand Down
2 changes: 1 addition & 1 deletion patches/react-navigation/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

### [@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch)

- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays.
- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. Native reporting the tab that sits under a focused hidden route, as Android does when it rejects an unchanged request as repeated on a re-render, confirms the state instead of moving JS to that tab, so the hidden route stays open. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays.
- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8.
- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339)
Expand Down
2 changes: 2 additions & 0 deletions src/components/LHNOptionsList/LHNOptionsList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {renderTabRootScrollComponent} from '@components/Navigation/TabBarBottomContent/TabRootScrollViewMarker';
import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';

Expand Down Expand Up @@ -180,6 +181,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
maintainVisibleContentPosition={{disabled: true}}
drawDistance={250}
removeClippedSubviews
renderScrollComponent={renderTabRootScrollComponent}
/>
</LHNTooltipContextProvider>
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import useThemeStyles from '@hooks/useThemeStyles';

import type {ScrollViewProps} from 'react-native';

import React from 'react';
// FlashList's default scroll component is React Native's Animated.ScrollView, not Reanimated's.
// eslint-disable-next-line no-restricted-imports
import {Animated} from 'react-native';
import {ScrollViewMarker} from 'react-native-screens/experimental';

import type TabRootScrollViewMarkerProps from './types';

/**
* A second tap on the focused native tab scrolls the tab's content to the top. Without a marker, react-native-screens
* scrolls the first scroll view it finds by following each view's first child, which on a screen with a horizontal
* selector above its list is the selector. The marker has to be the scroll view's direct native parent.
*/
function TabRootScrollViewMarker({children}: TabRootScrollViewMarkerProps) {
const styles = useThemeStyles();

return <ScrollViewMarker style={styles.flex1}>{children}</ScrollViewMarker>;
}

/**
* For a FlashList's `renderScrollComponent`, which replaces the list's own ScrollView rather than wrapping it. It renders
* the list's default scroll component, Animated.ScrollView. FlashList passes the ref among the props, so it reaches it.
*/
function renderTabRootScrollComponent(props: ScrollViewProps) {
return (
<TabRootScrollViewMarker>
<Animated.ScrollView {...props} />
</TabRootScrollViewMarker>
);
}

export default TabRootScrollViewMarker;
export {renderTabRootScrollComponent};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type TabRootScrollViewMarkerProps from './types';

/** On web the JS tab bar has no scroll to top on a second tap, so there is nothing to mark. */
function TabRootScrollViewMarker({children}: TabRootScrollViewMarkerProps) {
return children;
}

/** Lists keep their default scroll component. */
const renderTabRootScrollComponent = undefined;

export default TabRootScrollViewMarker;
export {renderTabRootScrollComponent};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type {ReactElement} from 'react';

type TabRootScrollViewMarkerProps = {
/** The tab's vertical scroll view, rendered as the marker's only child */
children: ReactElement;
};

export default TabRootScrollViewMarkerProps;
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ type NativeTabLayoutProps = Parameters<NonNullable<NativeBottomTabNavigatorProps
function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();
// The native tab host can't drop a tab it already shows: on Android the removed tab keeps its bar item and keeps
// firing selection events. Remounting the host when the set of tabs without a bar item changes (the Insights beta
// loading after launch or being toggled) gives it only the tabs it should show.
const tabsWithoutBarItemKey = state.routes
.filter((route) => descriptors[route.key]?.options.tabBarItemHidden)
.map((route) => route.name)
.join();

// The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them.
return (
<View style={[styles.flex1, !shouldUseNarrowLayout && styles.flexRow]}>
{!shouldUseNarrowLayout && <TabNavigatorBar state={state} />}
<View style={styles.flex1}>{children}</View>
<View
key={tabsWithoutBarItemKey}
style={styles.flex1}
>
{children}
</View>
<TabPressListeners
state={state}
descriptors={descriptors}
Expand Down
Loading
Loading