@react-navigation/bottom-tabs 8.0.0-alpha.12 → 8.0.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import {
2
- Lazy,
2
+ ActivityView,
3
+ Container,
3
4
  SafeAreaProviderCompat,
4
5
  } from '@react-navigation/elements/internal';
5
6
  import {
@@ -16,7 +17,6 @@ import {
16
17
  StyleSheet,
17
18
  type ViewStyle,
18
19
  } from 'react-native';
19
- import { Screen, ScreenContainer } from 'react-native-screens';
20
20
 
21
21
  import {
22
22
  FadeTransition,
@@ -44,11 +44,6 @@ type Props = BottomTabNavigationConfig & {
44
44
  descriptors: BottomTabDescriptorMap;
45
45
  };
46
46
 
47
- const EPSILON = 1e-5;
48
- const STATE_INACTIVE = 0;
49
- const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
50
- const STATE_ON_TOP = 2;
51
-
52
47
  const NAMED_TRANSITIONS_PRESETS = {
53
48
  fade: FadeTransition,
54
49
  shift: ShiftTransition,
@@ -82,22 +77,43 @@ export function BottomTabViewCustom({
82
77
  state,
83
78
  navigation,
84
79
  descriptors,
85
- detachInactiveScreens = Platform.OS === 'web' ||
86
- Platform.OS === 'android' ||
87
- Platform.OS === 'ios',
88
80
  }: Props) {
89
81
  const { routes } = state;
90
82
  const focusedRouteKey = routes[state.index].key;
91
83
 
92
- const previousRouteKeyRef = React.useRef(focusedRouteKey);
84
+ const [loaded, setLoaded] = React.useState([focusedRouteKey]);
85
+
86
+ if (!loaded.includes(focusedRouteKey)) {
87
+ setLoaded([...loaded, focusedRouteKey]);
88
+ }
89
+
90
+ const [lastUpdate, setLastUpdate] = React.useState<{
91
+ current: string;
92
+ previous?: string;
93
+ }>({
94
+ current: focusedRouteKey,
95
+ });
96
+
97
+ if (lastUpdate.current !== focusedRouteKey) {
98
+ setLastUpdate({
99
+ current: focusedRouteKey,
100
+ previous: lastUpdate.current,
101
+ });
102
+ }
103
+
93
104
  const tabAnims = useAnimatedHashMap(state);
94
105
 
106
+ const [isAnimating, setIsAnimating] = React.useState(false);
107
+
108
+ const previousRouteKeyRef = React.useRef(focusedRouteKey);
109
+
95
110
  React.useEffect(() => {
96
111
  const previousRouteKey = previousRouteKeyRef.current;
97
112
 
98
113
  let popToTopAction: NavigationAction | undefined;
99
114
 
100
115
  if (
116
+ previousRouteKey &&
101
117
  previousRouteKey !== focusedRouteKey &&
102
118
  descriptors[previousRouteKey]?.options.popToTopOnBlur
103
119
  ) {
@@ -105,6 +121,12 @@ export function BottomTabViewCustom({
105
121
  (route) => route.key === previousRouteKey
106
122
  );
107
123
 
124
+ console.log(
125
+ 'checking if we need to pop to top for route',
126
+ previousRouteKey,
127
+ focusedRouteKey
128
+ );
129
+
108
130
  if (prevRoute?.state?.type === 'stack' && prevRoute.state.key) {
109
131
  popToTopAction = {
110
132
  ...StackActions.popToTop(),
@@ -113,6 +135,8 @@ export function BottomTabViewCustom({
113
135
  }
114
136
  }
115
137
 
138
+ let timer: ReturnType<typeof setTimeout>;
139
+
116
140
  const animateToIndex = () => {
117
141
  if (previousRouteKey !== focusedRouteKey) {
118
142
  navigation.emit({
@@ -121,42 +145,45 @@ export function BottomTabViewCustom({
121
145
  });
122
146
  }
123
147
 
124
- Animated.parallel(
125
- state.routes
126
- .map((route, index) => {
127
- const { options } = descriptors[route.key];
128
- const {
129
- animation = 'none',
130
- transitionSpec = NAMED_TRANSITIONS_PRESETS[animation]
131
- .transitionSpec,
132
- } = options;
133
-
134
- let spec = transitionSpec;
135
-
136
- if (
137
- route.key !== previousRouteKey &&
138
- route.key !== focusedRouteKey
139
- ) {
140
- // Don't animate if the screen is not previous one or new one
141
- // This will avoid flicker for screens not involved in the transition
142
- spec = NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
143
- }
144
-
145
- spec = spec ?? NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
146
-
147
- const toValue =
148
- index === state.index ? 0 : index >= state.index ? 1 : -1;
149
-
150
- return Animated[spec.animation](tabAnims[route.key], {
151
- ...spec.config,
152
- toValue,
153
- useNativeDriver,
154
- });
155
- })
156
- .filter(Boolean) as Animated.CompositeAnimation[]
157
- ).start(({ finished }) => {
148
+ const animations = state.routes
149
+ .map((route, index) => {
150
+ const { options } = descriptors[route.key];
151
+ const {
152
+ animation = 'none',
153
+ transitionSpec = NAMED_TRANSITIONS_PRESETS[animation]
154
+ .transitionSpec,
155
+ } = options;
156
+
157
+ let spec = transitionSpec;
158
+
159
+ if (route.key !== previousRouteKey && route.key !== focusedRouteKey) {
160
+ // Don't animate if the screen is not previous one or new one
161
+ // This will avoid flicker for screens not involved in the transition
162
+ spec = NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
163
+ }
164
+
165
+ spec = spec ?? NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
166
+
167
+ const toValue =
168
+ index === state.index ? 0 : index >= state.index ? 1 : -1;
169
+
170
+ return Animated[spec.animation](tabAnims[route.key], {
171
+ ...spec.config,
172
+ toValue,
173
+ useNativeDriver,
174
+ });
175
+ })
176
+ .filter((anim) => anim != null);
177
+
178
+ if (animations.length) {
179
+ // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
180
+ setIsAnimating(true);
181
+ }
182
+
183
+ Animated.parallel(animations).start(({ finished }) => {
158
184
  if (finished && popToTopAction) {
159
- navigation.dispatch(popToTopAction);
185
+ console.log('dispatching pop to top action', popToTopAction);
186
+ // navigation.dispatch(popToTopAction);
160
187
  }
161
188
 
162
189
  if (previousRouteKey !== focusedRouteKey) {
@@ -165,12 +192,24 @@ export function BottomTabViewCustom({
165
192
  target: focusedRouteKey,
166
193
  });
167
194
  }
195
+
196
+ if (finished && animations.length) {
197
+ // Delay clearing `isAnimating`
198
+ // This will give time for `popToAction` to get handled before pause
199
+ timer = setTimeout(() => {
200
+ setIsAnimating(false);
201
+ }, 32);
202
+ }
168
203
  });
169
204
  };
170
205
 
171
206
  animateToIndex();
172
207
 
173
208
  previousRouteKeyRef.current = focusedRouteKey;
209
+
210
+ return () => {
211
+ clearTimeout(timer);
212
+ };
174
213
  }, [
175
214
  descriptors,
176
215
  focusedRouteKey,
@@ -203,11 +242,6 @@ export function BottomTabViewCustom({
203
242
  </BottomTabBarHeightCallbackContext.Provider>
204
243
  );
205
244
 
206
- // If there is no animation, we only have 2 states: visible and invisible
207
- const hasTwoStates = !routes.some((route) =>
208
- hasAnimation(descriptors[route.key].options)
209
- );
210
-
211
245
  const tabBarPosition = useTabBarPosition(
212
246
  descriptors[focusedRouteKey].options
213
247
  );
@@ -224,12 +258,7 @@ export function BottomTabViewCustom({
224
258
  {tabBarPosition === 'top' || tabBarPosition === 'left'
225
259
  ? tabBarElement
226
260
  : null}
227
- <ScreenContainer
228
- key="screens"
229
- enabled={detachInactiveScreens}
230
- hasTwoStates={hasTwoStates}
231
- style={styles.screens}
232
- >
261
+ <Container key="screens" style={styles.screens}>
233
262
  {routes.map((route, index) => {
234
263
  const descriptor = descriptors[route.key];
235
264
 
@@ -237,6 +266,7 @@ export function BottomTabViewCustom({
237
266
 
238
267
  const {
239
268
  lazy = true,
269
+ inactiveBehavior = 'pause',
240
270
  animation = 'none',
241
271
  sceneStyleInterpolator = NAMED_TRANSITIONS_PRESETS[animation]
242
272
  .sceneStyleInterpolator,
@@ -246,6 +276,16 @@ export function BottomTabViewCustom({
246
276
  const isFocused = state.index === index;
247
277
  const isPreloaded = state.preloadedRouteKeys.includes(route.key);
248
278
 
279
+ if (
280
+ lazy &&
281
+ !loaded.includes(route.key) &&
282
+ !isFocused &&
283
+ !isPreloaded
284
+ ) {
285
+ // Don't render a lazy screen if we've never navigated to it or it wasn't preloaded
286
+ return null;
287
+ }
288
+
249
289
  const animationEnabled = hasAnimation(descriptor.options);
250
290
 
251
291
  const content = (
@@ -254,75 +294,48 @@ export function BottomTabViewCustom({
254
294
  progress={tabAnims[route.key]}
255
295
  animationEnabled={animationEnabled}
256
296
  sceneStyleInterpolator={sceneStyleInterpolator}
257
- style={customSceneStyle}
297
+ style={[StyleSheet.absoluteFill, customSceneStyle]}
258
298
  >
259
- <Lazy enabled={lazy} visible={isFocused || isPreloaded}>
260
- <ScreenContent
261
- isFocused={isFocused}
262
- route={route}
263
- navigation={navigation}
264
- options={options}
265
- style={
266
- Platform.OS === 'web'
267
- ? {
268
- /**
269
- * Don't use react-native-screens on web:
270
- * - It applies display: none as fallback, which triggers `onLayout` events
271
- * - We still need to hide the view when screens is not enabled
272
- */
273
- ...StyleSheet.absoluteFillObject,
274
- visibility: isFocused ? 'visible' : 'hidden',
275
- }
276
- : undefined
277
- }
299
+ <ScreenContent
300
+ isFocused={isFocused}
301
+ route={route}
302
+ navigation={navigation}
303
+ options={options}
304
+ >
305
+ <BottomTabBarHeightContext.Provider
306
+ value={tabBarPosition === 'bottom' ? tabBarHeight : 0}
278
307
  >
279
- <BottomTabBarHeightContext.Provider
280
- value={tabBarPosition === 'bottom' ? tabBarHeight : 0}
281
- >
282
- {render()}
283
- </BottomTabBarHeightContext.Provider>
284
- </ScreenContent>
285
- </Lazy>
308
+ {render()}
309
+ </BottomTabBarHeightContext.Provider>
310
+ </ScreenContent>
286
311
  </AnimatedScreenContent>
287
312
  );
288
313
 
289
- if (Platform.OS === 'web') {
290
- return content;
291
- }
314
+ const isAnimatingRoute =
315
+ isAnimating &&
316
+ (lastUpdate.previous === route.key ||
317
+ lastUpdate.current === route.key);
292
318
 
293
- const activityState = isFocused
294
- ? STATE_ON_TOP // the screen is on top after the transition
295
- : animationEnabled // is animation is not enabled, immediately move to inactive state
296
- ? tabAnims[route.key].interpolate({
297
- inputRange: [0, 1 - EPSILON, 1],
298
- outputRange: [
299
- STATE_TRANSITIONING_OR_BELOW_TOP, // screen visible during transition
300
- STATE_TRANSITIONING_OR_BELOW_TOP,
301
- STATE_INACTIVE, // the screen is detached after transition
302
- ],
303
- extrapolate: 'extend',
304
- })
305
- : STATE_INACTIVE;
319
+ // For preloaded screens and if lazy is false,
320
+ // Keep them active so that the effects can run
321
+ const isActive =
322
+ inactiveBehavior === 'none' ||
323
+ isAnimatingRoute ||
324
+ isPreloaded ||
325
+ (lazy === false && !loaded.includes(route.key));
306
326
 
307
327
  return (
308
- <Screen
328
+ <ActivityView
309
329
  key={route.key}
310
- style={[
311
- StyleSheet.absoluteFill,
312
- {
313
- zIndex: isFocused ? 0 : -1,
314
- pointerEvents: isFocused ? 'auto' : 'none',
315
- },
316
- ]}
317
- activityState={activityState}
318
- enabled={detachInactiveScreens}
319
- shouldFreeze={activityState === STATE_INACTIVE && !isPreloaded}
330
+ mode={isFocused ? 'normal' : isActive ? 'inert' : 'paused'}
331
+ visible={isFocused || isAnimatingRoute}
332
+ style={{ ...StyleSheet.absoluteFill, zIndex: isFocused ? 0 : -1 }}
320
333
  >
321
334
  {content}
322
- </Screen>
335
+ </ActivityView>
323
336
  );
324
337
  })}
325
- </ScreenContainer>
338
+ </Container>
326
339
  {tabBarPosition === 'bottom' || tabBarPosition === 'right'
327
340
  ? tabBarElement
328
341
  : null}
@@ -339,7 +352,7 @@ function AnimatedScreenContent({
339
352
  }: {
340
353
  progress: Animated.Value;
341
354
  animationEnabled: boolean;
342
- sceneStyleInterpolator?: BottomTabSceneStyleInterpolator;
355
+ sceneStyleInterpolator?: BottomTabSceneStyleInterpolator | undefined;
343
356
  children: React.ReactNode;
344
357
  style: StyleProp<ViewStyle>;
345
358
  }) {
@@ -1,7 +1,7 @@
1
1
  import { getLabel } from '@react-navigation/elements';
2
2
  import {
3
+ ActivityView,
3
4
  Color,
4
- Lazy,
5
5
  SafeAreaProviderCompat,
6
6
  } from '@react-navigation/elements/internal';
7
7
  import {
@@ -20,6 +20,7 @@ import {
20
20
  type ColorValue,
21
21
  Platform,
22
22
  PlatformColor,
23
+ StyleSheet,
23
24
  } from 'react-native';
24
25
  import {
25
26
  type PlatformIcon,
@@ -62,6 +63,18 @@ export function BottomTabViewNative({
62
63
  const { dark, colors, fonts } = useTheme();
63
64
 
64
65
  const focusedRouteKey = state.routes[state.index].key;
66
+
67
+ const [loaded, setLoaded] = React.useState([focusedRouteKey]);
68
+
69
+ if (!loaded.includes(focusedRouteKey)) {
70
+ setLoaded([...loaded, focusedRouteKey]);
71
+ }
72
+
73
+ const [pendingNavigation, setPendingNavigation] = React.useState<{
74
+ from: string;
75
+ to: string;
76
+ } | null>(null);
77
+
65
78
  const previousRouteKeyRef = React.useRef(focusedRouteKey);
66
79
 
67
80
  React.useEffect(() => {
@@ -80,11 +93,26 @@ export function BottomTabViewNative({
80
93
  ...StackActions.popToTop(),
81
94
  target: prevRoute.state.key,
82
95
  };
96
+
83
97
  navigation.dispatch(popToTopAction);
84
98
  }
85
99
  }
86
100
 
87
101
  previousRouteKeyRef.current = focusedRouteKey;
102
+
103
+ // Delay clearing `isAnimating`
104
+ // This will give time for `popToAction` to get handled before pause
105
+ const timer = setTimeout(() => {
106
+ setPendingNavigation((pending) => {
107
+ if (pending?.to === focusedRouteKey) {
108
+ return null;
109
+ }
110
+
111
+ return pending;
112
+ });
113
+ }, 32);
114
+
115
+ return () => clearTimeout(timer);
88
116
  }, [descriptors, focusedRouteKey, navigation, state.index, state.routes]);
89
117
 
90
118
  const currentOptions = descriptors[state.routes[state.index].key]?.options;
@@ -106,6 +134,8 @@ export function BottomTabViewNative({
106
134
  const backgroundColor =
107
135
  currentOptions.tabBarStyle?.backgroundColor ?? colors.background;
108
136
 
137
+ const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none';
138
+
109
139
  let activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor;
110
140
  let activeTintColor = currentOptions.tabBarActiveTintColor;
111
141
  let inactiveTintColor = currentOptions.tabBarInactiveTintColor;
@@ -212,7 +242,7 @@ export function BottomTabViewNative({
212
242
  ? tabBarElement
213
243
  : null}
214
244
  <Tabs.Host
215
- tabBarHidden={hasCustomTabBar}
245
+ tabBarHidden={hasCustomTabBar || shouldHideTabBar}
216
246
  bottomAccessory={
217
247
  bottomAccessory
218
248
  ? (environment) => bottomAccessory({ placement: environment })
@@ -263,6 +293,11 @@ export function BottomTabViewNative({
263
293
  state.routes.findIndex((r) => r.key === route.key);
264
294
 
265
295
  if (!isFocused) {
296
+ setPendingNavigation({
297
+ from: previousRouteKeyRef.current,
298
+ to: route.key,
299
+ });
300
+
266
301
  navigation.dispatch({
267
302
  ...CommonActions.navigate(route.name, route.params),
268
303
  target: state.key,
@@ -279,6 +314,7 @@ export function BottomTabViewNative({
279
314
  const {
280
315
  title,
281
316
  lazy = true,
317
+ inactiveBehavior = 'pause',
282
318
  tabBarLabel,
283
319
  tabBarBadgeStyle,
284
320
  tabBarIcon,
@@ -353,6 +389,15 @@ export function BottomTabViewNative({
353
389
  const icon = getIcon(false);
354
390
  const selectedIcon = getIcon(true);
355
391
 
392
+ // For preloaded screens and if lazy is false,
393
+ // Keep them active so that the effects can run
394
+ const isActive =
395
+ inactiveBehavior === 'none' ||
396
+ isPreloaded ||
397
+ pendingNavigation?.from === route.key ||
398
+ pendingNavigation?.to === route.key ||
399
+ (lazy === false && !loaded.includes(route.key));
400
+
356
401
  return (
357
402
  <Tabs.Screen
358
403
  onWillAppear={() => onTransitionStart({ route })}
@@ -387,9 +432,20 @@ export function BottomTabViewNative({
387
432
  ? 'automatic'
388
433
  : scrollEdgeEffects?.right,
389
434
  }}
390
- // FIXME: if this is not provided, ScrollView on lazy tabs glitches on iOS 18
391
- // For now we provide an empty object before adding proper support
392
- scrollEdgeAppearance={{}}
435
+ scrollEdgeAppearance={{
436
+ tabBarBackgroundColor,
437
+ tabBarShadowColor,
438
+ tabBarBlurEffect,
439
+ stacked: {
440
+ normal: tabItemAppearance,
441
+ },
442
+ inline: {
443
+ normal: tabItemAppearance,
444
+ },
445
+ compactInline: {
446
+ normal: tabItemAppearance,
447
+ },
448
+ }}
393
449
  standardAppearance={{
394
450
  tabBarBackgroundColor,
395
451
  tabBarShadowColor,
@@ -415,23 +471,38 @@ export function BottomTabViewNative({
415
471
  }
416
472
  experimental_userInterfaceStyle={dark ? 'dark' : 'light'}
417
473
  >
418
- <Lazy enabled={lazy} visible={isFocused || isPreloaded}>
419
- <ScreenContent
420
- isFocused={isFocused}
421
- route={route}
422
- navigation={navigation}
423
- options={options}
424
- style={sceneStyle}
474
+ {lazy &&
475
+ !loaded.includes(route.key) &&
476
+ !isFocused &&
477
+ !isPreloaded ? null : (
478
+ <ActivityView
479
+ key={route.key}
480
+ mode={isFocused ? 'normal' : isActive ? 'inert' : 'paused'}
481
+ visible={
482
+ // We don't need to hide the content since it's handled natively
483
+ // Hiding may also cause flash due to lag after native tab switch
484
+ // So we leave it always visible
485
+ true
486
+ }
487
+ style={StyleSheet.absoluteFill}
425
488
  >
426
- <AnimatedScreenContent isFocused={isFocused}>
427
- <BottomTabBarHeightContext.Provider value={0}>
428
- <NavigationMetaContext.Provider value={meta}>
429
- {render()}
430
- </NavigationMetaContext.Provider>
431
- </BottomTabBarHeightContext.Provider>
432
- </AnimatedScreenContent>
433
- </ScreenContent>
434
- </Lazy>
489
+ <ScreenContent
490
+ isFocused={isFocused}
491
+ route={route}
492
+ navigation={navigation}
493
+ options={options}
494
+ style={sceneStyle}
495
+ >
496
+ <AnimatedScreenContent isFocused={isFocused}>
497
+ <BottomTabBarHeightContext.Provider value={0}>
498
+ <NavigationMetaContext.Provider value={meta}>
499
+ {render()}
500
+ </NavigationMetaContext.Provider>
501
+ </BottomTabBarHeightContext.Provider>
502
+ </AnimatedScreenContent>
503
+ </ScreenContent>
504
+ </ActivityView>
505
+ )}
435
506
  </Tabs.Screen>
436
507
  );
437
508
  })}
@@ -18,8 +18,8 @@ export type TabBarIconProps = {
18
18
  route: Route<string>;
19
19
  variant: 'uikit' | 'material';
20
20
  size: 'compact' | 'regular';
21
- badge?: string | number;
22
- badgeStyle?: StyleProp<TextStyle>;
21
+ badge?: string | number | undefined;
22
+ badgeStyle?: StyleProp<TextStyle> | undefined;
23
23
  activeOpacity: number;
24
24
  inactiveOpacity: number;
25
25
  activeTintColor: ColorValue;
@@ -31,7 +31,7 @@ export type TabBarIconProps = {
31
31
  color: ColorValue;
32
32
  size: number;
33
33
  }) => BottomTabIcon | React.ReactNode);
34
- allowFontScaling?: boolean;
34
+ allowFontScaling?: boolean | undefined;
35
35
  style: StyleProp<ViewStyle>;
36
36
  };
37
37