@rn-tools/navigation 2.0.0 → 2.2.0

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.
package/README.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # @rn-tools/navigation
2
2
 
3
- A set of useful navigation components for React Native. Built with `react-native-screens`. Designed with flexibility in mind.
3
+ A set of useful navigation components for React Native. Built with `react-native-screens` and designed with flexibility in mind.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation](#installation)
8
+ - [Basic Usage](#basic-usage)
9
+ - [Stack Navigator](#stack-navigator)
10
+ - [Tab Navigator](#tab-navigator)
11
+ - [Rendering a stack inside of a tabbed screen](#rendering-a-stack-inside-of-a-tabbed-screen)
12
+ - [Targeting a specific stack](#targeting-a-specific-stack)
13
+ - [Pushing a screen once](#pushing-a-screen-once)
14
+ - [Targeting specific tabs](#targeting-specific-tabs)
15
+ - [Rendering a header](#rendering-a-header)
16
+ - [Components](#components)
17
+ - [Stack](#stack)
18
+ - [Tabs](#tabs)
19
+ - [Guides](#guides)
20
+ - [Authentication](#authentication)
21
+ - [Deep Links](#deep-links)
22
+ - [Preventing going back](#preventing-going-back)
4
23
 
5
24
  ## Installation
6
25
 
@@ -8,13 +27,23 @@ A set of useful navigation components for React Native. Built with `react-native
8
27
  yarn expo install @rn-tools/navigation react-native-screens
9
28
  ```
10
29
 
30
+ **Note:** It's recommended that you install and wrap your app in a `SafeAreaProvider` to ensure components are rendered correctly based on the device's insets:
31
+
32
+ ```bash
33
+ yarn expo install react-native-safe-area-context
34
+ ```
35
+
11
36
  ## Basic Usage
12
37
 
13
- For basic usage, the exported `Stack.Navigator` and `Tabs.Navigator` will get you up and running quickly. The [Guides](#guides) section covers how to use lower-level `Stack` and `Tabs` components in a variety of navigation patterns.
38
+ For basic usage, the exported `Stack.Navigator` and `Tabs.Navigator` will get you up and running quickly.
39
+
40
+ The [Guides](#guides) section covers how to use lower-level `Stack` and `Tabs` components in a variety of navigation patterns.
41
+
42
+ `Stack` and `Tabs` are composable components that can be safely nested within each other without any additional configuration or setup.
14
43
 
15
44
  ### Stack Navigator
16
45
 
17
- The `Stack.Navigator` component manages stacks of screens. Under the hood this is using `react-native-screens` to handle pushing and popping natively.
46
+ The `Stack.Navigator` component manages screens. Under the hood this is using `react-native-screens` to handle pushing and popping natively.
18
47
 
19
48
  Screens are pushed and popped by the exported navigation methods:
20
49
 
@@ -22,6 +51,8 @@ Screens are pushed and popped by the exported navigation methods:
22
51
 
23
52
  - `navigation.popScreen(numberOfScreens: number) => void`
24
53
 
54
+ In the majority of cases, these methods will determine the right stack without you needing to specify. But you can target a specific stacks as well if you need to! This is covered in the [Targeting a specific stack](#targeting-a-specific-stack) section.
55
+
25
56
  ```tsx
26
57
  import { Stack, navigation } from "@rn-tools/navigation";
27
58
  import * as React from "react";
@@ -33,15 +64,17 @@ export function BasicStack() {
33
64
 
34
65
  function MyScreen({
35
66
  title,
36
- showPopButton = false,
67
+ children,
37
68
  }: {
38
69
  title: string;
39
- showPopButton?: boolean;
70
+ children?: React.ReactNode;
40
71
  }) {
41
72
  function pushScreen() {
42
73
  navigation.pushScreen(
43
74
  <Stack.Screen>
44
- <MyScreen title="Pushed screen" showPopButton />
75
+ <MyScreen title="Pushed screen">
76
+ <Button title="Pop screen" onPress={popScreen} />
77
+ </MyScreen>
45
78
  </Stack.Screen>
46
79
  );
47
80
  }
@@ -54,13 +87,13 @@ function MyScreen({
54
87
  <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
55
88
  <Text>{title}</Text>
56
89
  <Button title="Push screen" onPress={pushScreen} />
57
- {showPopButton && <Button title="Pop screen" onPress={popScreen} />}
90
+ {children}
58
91
  </View>
59
92
  );
60
93
  }
61
94
  ```
62
95
 
63
- **Note**: The components passed to `navigation.pushScreen` need to be wrapped in a `Stack.Screen` component. Create a wrapper to simplify your usage if you'd like:
96
+ **Note**: The components passed to `navigation.pushScreen` need to be wrapped in a `Stack.Screen`. Create a wrapper to simplify your usage if you'd like:
64
97
 
65
98
  ```tsx
66
99
  function myPushScreen(
@@ -73,39 +106,31 @@ function myPushScreen(
73
106
 
74
107
  ### Tab Navigator
75
108
 
76
- The `Tabs.Navigator` component also uses `react-native-screens` to handle the tab switching natively. The active tab can be changed via the `navigation.setTabIndex` method, however the build in tabbar already handles switching between screens.
109
+ The `Tabs.Navigator` component also uses `react-native-screens` to handle switching between tabs natively.
110
+
111
+ The active tab can be changed via the `navigation.setTabIndex` method, however the built in tabbar handles switching between screens out of the box.
77
112
 
78
113
  ```tsx
79
- import {
80
- Stack,
81
- Tabs,
82
- navigation,
83
- Stack,
84
- defaultTabbarStyle,
85
- } from "@rn-tools/navigation";
114
+ import { Tabs, navigation, Stack } from "@rn-tools/navigation";
86
115
  import * as React from "react";
87
116
  import { View, Text, Button } from "react-native";
88
- import { useSafeAreaInsets } from "react-native-safe-area-context";
117
+
118
+ // It's recommended to wrap your App in a SafeAreaProvider once
119
+ import { SafeAreaProvider } from "react-native-safe-area-context";
89
120
 
90
121
  export function BasicTabs() {
91
- return <Stack.Navigator rootScreen={<MyTabs />} />;
122
+ return (
123
+ <SafeAreaProvider>
124
+ <Stack.Navigator rootScreen={<MyTabs />} />
125
+ </SafeAreaProvider>
126
+ );
92
127
  }
93
128
 
94
129
  function MyTabs() {
95
- // This hook requires you to wrap your app in a SafeAreaProvider component - see the `react-native-safe-area-context` package
96
- let insets = useSafeAreaInsets();
97
-
98
- let tabbarStyle = React.useMemo(() => {
99
- return {
100
- ...defaultTabbarStyle,
101
- bottom: insets.bottom,
102
- };
103
- }, [insets.bottom]);
104
-
105
130
  return (
106
131
  <Tabs.Navigator
107
132
  tabbarPosition="bottom"
108
- tabbarStyle={tabbarStyle}
133
+ tabbarStyle={{ backgroundColor: "blue" }}
109
134
  screens={[
110
135
  {
111
136
  key: "1",
@@ -147,17 +172,19 @@ function MyTab({
147
172
 
148
173
  function MyScreen({
149
174
  title,
150
- showPopButton = false,
175
+ children,
151
176
  bg,
152
177
  }: {
153
178
  title: string;
154
- showPopButton?: boolean;
179
+ children?: React.ReactNode;
155
180
  bg?: string;
156
181
  }) {
157
182
  function pushScreen() {
158
183
  navigation.pushScreen(
159
184
  <Stack.Screen>
160
- <MyScreen title="Pushed screen" showPopButton />
185
+ <MyScreen title="Pushed screen" bg={bg}>
186
+ <Button title="Pop screen" onPress={popScreen} />
187
+ </MyScreen>
161
188
  </Stack.Screen>
162
189
  );
163
190
  }
@@ -177,7 +204,7 @@ function MyScreen({
177
204
  >
178
205
  <Text>{title}</Text>
179
206
  <Button title="Push screen" onPress={pushScreen} />
180
- {showPopButton && <Button title="Pop screen" onPress={popScreen} />}
207
+ {children}
181
208
  </View>
182
209
  );
183
210
  }
@@ -187,8 +214,6 @@ function MyScreen({
187
214
 
188
215
  Each tab can have its own stack by nesting the `Stack.Navigator` component.
189
216
 
190
- - `navigation.pushScreen` will still work relative by pushing to the relative parent stack of the screen. See the next section for how to push a screen onto a specific stack.
191
-
192
217
  ```tsx
193
218
  function MyTabs() {
194
219
  return (
@@ -272,9 +297,76 @@ function switchMainTabsToTab(tabIndex: number) {
272
297
  }
273
298
  ```
274
299
 
300
+ ### Rendering a header
301
+
302
+ Use the `Stack.Header` component to render a native header in a screen.
303
+
304
+ Under the hood this is using `react-native-screens` header - [here is a reference for the available props](https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screenstackheaderconfig)
305
+
306
+ You can provide custom left, center, and right views in the header by using the `Stack.HeaderLeft`, `Stack.HeaderCenter`, and `Stack.HeaderRight` view container components as children of `Stack.Header`.
307
+
308
+ **Note:** Wrap your App in a `SafeAreaProvider` to ensure your screen components are rendered correctly with the header
309
+
310
+ **Note:**: The header component **has to be the first child** of a `Stack.Screen` component.
311
+
312
+ ```tsx
313
+ import { navigation, Stack } from "@rn-tools/navigation";
314
+ import * as React from "react";
315
+ import { Button, View, TextInput, Text } from "react-native";
316
+
317
+ export function HeaderExample() {
318
+ return (
319
+ <View>
320
+ <Button
321
+ title="Push screen with header"
322
+ onPress={() => navigation.pushScreen(<MyScreenWithHeader />)}
323
+ />
324
+ </View>
325
+ );
326
+ }
327
+
328
+ function MyScreenWithHeader() {
329
+ let [title, setTitle] = React.useState("");
330
+
331
+ return (
332
+ <Stack.Screen>
333
+ {/* Header must be the first child */}
334
+ <Stack.Header
335
+ title={title}
336
+ // Some potentially useful props - see the reference posted above for all available props
337
+ backTitle="Custom back title"
338
+ backTitleFontSize={16}
339
+ hideBackButton={false}
340
+ >
341
+ <Stack.HeaderRight>
342
+ <Text>Custom right text!</Text>
343
+ </Stack.HeaderRight>
344
+ </Stack.Header>
345
+
346
+ <View
347
+ style={{
348
+ flex: 1,
349
+ alignItems: "center",
350
+ paddingVertical: 48,
351
+ }}
352
+ >
353
+ <TextInput
354
+ style={{ fontSize: 26, fontWeight: "semibold" }}
355
+ value={title}
356
+ onChangeText={setTitle}
357
+ placeholder="Enter header text"
358
+ />
359
+ </View>
360
+ </Stack.Screen>
361
+ );
362
+ }
363
+ ```
364
+
275
365
  ## Components
276
366
 
277
- The `Navigator` components in the previous examples are convenience wrappers around other lower level `Stack` and `Tabs` components. This section will briefly cover each of the underlying components so that you can build your own wrappers if needed
367
+ The `Navigator` components in the previous examples are fairly straightforward wrappers around other lower level `Stack` and `Tabs` components.
368
+
369
+ If you need to customize behaviour, design a component API you prefer to use, or just enjoy writing your own components, you can use these implementations as a reference to build your own.
278
370
 
279
371
  ### Stack
280
372
 
@@ -311,30 +403,47 @@ export function StackNavigator({
311
403
  - Reference for props that can be passed: [Screen Props](https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screen)
312
404
  - `Stack.Slot` - A slot for screens to be pushed into.
313
405
  - This component is used to render screens that are pushed using `navigation.pushScreen` - don't forget to render this somewhere in `Stack.Screens`!
406
+ - `Stack.Header` - A header for a screen.
407
+ - **Must be rendered as the first child of a `Stack.Screen` component.**
408
+ - This is a `react-native-screens` StackHeader component under the hood.
409
+ - Reference for props that can be passed: [Header Props](https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screenstackheaderconfig)
314
410
 
315
411
  ## Tabs
316
412
 
317
413
  This is the implementation of the exported `Tabs.Navigator` component:
318
414
 
319
415
  ```tsx
320
- type TabsNavigatorProps = Omit<TabsRootProps, "children"> & {
321
- screens: Tabs.NavigatorScreenOptions[];
416
+ export type TabNavigatorProps = Omit<TabsRootProps, "children"> & {
417
+ screens: TabNavigatorScreenOptions[];
322
418
  tabbarPosition?: "top" | "bottom";
323
419
  tabbarStyle?: ViewProps["style"];
324
420
  };
325
421
 
326
- type TabsNavigatorScreenOptions = {
422
+ export type TabNavigatorScreenOptions = {
327
423
  key: string;
328
- screen: React.ReactElement<ScreenProps>;
424
+ screen: React.ReactElement<unknown>;
329
425
  tab: (props: { isActive: boolean; onPress: () => void }) => React.ReactNode;
330
426
  };
331
427
 
332
- export function Tabs.Navigator({
428
+ let TabNavigator = React.memo(function TabNavigator({
333
429
  screens,
334
430
  tabbarPosition = "bottom",
335
- tabbarStyle,
431
+ tabbarStyle: tabbarStyleProp,
336
432
  ...rootProps
337
- }: TabsNavigatorProps) {
433
+ }: TabNavigatorProps) {
434
+ let insets = useSafeAreaInsetsSafe();
435
+
436
+ let tabbarStyle = React.useMemo(() => {
437
+ return [
438
+ defaultTabbarStyle,
439
+ {
440
+ paddingBottom: tabbarPosition === "bottom" ? insets.bottom : 0,
441
+ paddingTop: tabbarPosition === "top" ? insets.top : 0,
442
+ },
443
+ tabbarStyleProp,
444
+ ];
445
+ }, [tabbarPosition, tabbarStyleProp, insets]);
446
+
338
447
  return (
339
448
  <Tabs.Root {...rootProps}>
340
449
  {tabbarPosition === "top" && (
@@ -360,7 +469,7 @@ export function Tabs.Navigator({
360
469
  )}
361
470
  </Tabs.Root>
362
471
  );
363
- }
472
+ });
364
473
  ```
365
474
 
366
475
  - `Tabs.Root` - The root component for a tabs navigator.
@@ -419,4 +528,218 @@ let useUser = () => {
419
528
  };
420
529
  ```
421
530
 
422
- **Note:** Screens that are pushed using `pushScreen` are rendered in the `Slot` component
531
+ ### Deep Links
532
+
533
+ This section will cover how to respond to deep links in your app. Deep links usually have some extra setup required - use Expo's [Deep Linking Guide](https://docs.expo.dev/guides/deep-linking/) to get started.
534
+
535
+ Once you are able to receive deep links, use the `DeepLinks` component exported from this library to handle them. In this example we will have a basic 3 tab view. We want to response to the link `home/items/:id` by navigating to the home tab and then pushing a detail screen with the corresponding id.
536
+
537
+ The deep link component takes an array of handlers which are functions that will be invoked when their `path` matches the deep link that was opened. The handler function will receive the params from the deep link - these use the same token syntax as libraries like `react-router` and `express` for path params.
538
+
539
+
540
+ ```tsx
541
+ import { DeepLinks, navigation, Stack, Tabs } from "@rn-tools/navigation";
542
+ import * as React from "react";
543
+ import { View, Text, TouchableOpacity } from "react-native";
544
+ import * as Linking from "expo-linking";
545
+
546
+ export function DeepLinksExample() {
547
+ // You'll likely want to use Expo's Linking API to get the current URL and path
548
+ // let url = Linking.useURL()
549
+ // let { path } = Linking.parse(url)
550
+
551
+ // But it's easier to test hardcoded strings for the sake of this example
552
+ let path = "/testing/home/item/4";
553
+
554
+ return (
555
+ <DeepLinks
556
+ path={path}
557
+ handlers={[
558
+ {
559
+ path: "/testing/home/item/:itemId",
560
+ handler: (params: { itemId: string }) => {
561
+ let itemId = params.itemId;
562
+
563
+ // Go to home tab
564
+ navigation.setTabIndex(0);
565
+
566
+ // Push the screen we want
567
+ navigation.pushScreen(
568
+ <Stack.Screen>
569
+ <MyScreen title={`Item: ${itemId}`} />
570
+ </Stack.Screen>
571
+ );
572
+ },
573
+ },
574
+ ]}
575
+ >
576
+ <MyTabs />
577
+ </DeepLinks>
578
+ );
579
+ }
580
+
581
+ function MyTabs() {
582
+ return (
583
+ <Tabs.Navigator
584
+ tabbarPosition="bottom"
585
+ screens={[
586
+ {
587
+ key: "1",
588
+ screen: (
589
+ <Stack.Navigator
590
+ rootScreen={<MyScreen bg="red" title="Home screen" isRoot />}
591
+ />
592
+ ),
593
+ tab: ({ isActive }) => <MyTab text="Home" isActive={isActive} />,
594
+ },
595
+ {
596
+ key: "2",
597
+ screen: (
598
+ <Stack.Navigator
599
+ rootScreen={<MyScreen bg="blue" title="Search screen" isRoot />}
600
+ />
601
+ ),
602
+ tab: ({ isActive }) => <MyTab text="Search" isActive={isActive} />,
603
+ },
604
+ {
605
+ key: "3",
606
+ screen: (
607
+ <Stack.Navigator
608
+ rootScreen={
609
+ <MyScreen bg="purple" title="Settings screen" isRoot />
610
+ }
611
+ />
612
+ ),
613
+ tab: ({ isActive }) => <MyTab text="Settings" isActive={isActive} />,
614
+ },
615
+ ]}
616
+ />
617
+ );
618
+ }
619
+
620
+ function MyTab({ isActive, text }: { isActive?: boolean; text: string }) {
621
+ return (
622
+ <View
623
+ style={{
624
+ padding: 16,
625
+ justifyContent: "center",
626
+ alignItems: "center",
627
+ }}
628
+ >
629
+ <Text style={{ fontSize: 12, fontWeight: isActive ? "bold" : "normal" }}>
630
+ {text}
631
+ </Text>
632
+ </View>
633
+ );
634
+ }
635
+
636
+ function MyScreen({
637
+ bg = "white",
638
+ title = "",
639
+ isRoot = false,
640
+ }: {
641
+ title?: string;
642
+ bg?: string;
643
+ isRoot?: boolean;
644
+ }) {
645
+ return (
646
+ <View style={{ flex: 1, backgroundColor: bg }}>
647
+ <View className="flex-1 items-center justify-center gap-4">
648
+ <Text style={{ fontSize: 26, fontWeight: "semibold" }}>{title}</Text>
649
+
650
+ {!isRoot && (
651
+ <TouchableOpacity
652
+ onPress={() => {
653
+ navigation.popScreen();
654
+ }}
655
+ >
656
+ <Text>Pop</Text>
657
+ </TouchableOpacity>
658
+ )}
659
+ </View>
660
+ </View>
661
+ );
662
+ }
663
+ ```
664
+
665
+ ### Preventing going back
666
+
667
+ If you want to prevent users from popping a screen and potentially losing unsaved data, you can stop the screen from being dismissed by a gesture or pressing the back button.
668
+
669
+ **Note:**: The native header component does not provide a reliable way to prevent going back on iOS, so you'll have to provide your own custom back button by using the `Stack.HeaderLeft` component
670
+
671
+ ```tsx
672
+ import { navigation, Stack } from "@rn-tools/navigation";
673
+ import * as React from "react";
674
+ import {
675
+ Text,
676
+ TextInput,
677
+ TouchableOpacity,
678
+ Button,
679
+ View,
680
+ Alert,
681
+ } from "react-native";
682
+
683
+ export function PreventGoingBack() {
684
+ return (
685
+ <Button
686
+ title="Push screen"
687
+ onPress={() => navigation.pushScreen(<MyScreen />)}
688
+ />
689
+ );
690
+ }
691
+
692
+ function MyScreen() {
693
+ let [input, setInput] = React.useState("");
694
+
695
+ let canGoBack = input.length === 0;
696
+
697
+ let onPressBackButton = React.useCallback(() => {
698
+ if (canGoBack) {
699
+ navigation.popScreen();
700
+ } else {
701
+ Alert.alert("Are you sure you want to go back?", "", [
702
+ {
703
+ text: "Cancel",
704
+ style: "cancel",
705
+ },
706
+ {
707
+ text: "Yes",
708
+ onPress: () => navigation.popScreen(),
709
+ },
710
+ ]);
711
+ }
712
+ }, [canGoBack]);
713
+
714
+ return (
715
+ <Stack.Screen
716
+ preventNativeDismiss={!canGoBack}
717
+ nativeBackButtonDismissalEnabled={!canGoBack}
718
+ gestureEnabled={canGoBack}
719
+ >
720
+ <Stack.Header title="Prevent going back">
721
+ <Stack.HeaderLeft>
722
+ <TouchableOpacity
723
+ onPress={onPressBackButton}
724
+ style={{ opacity: canGoBack ? 1 : 0.4 }}
725
+ >
726
+ <Text>Back</Text>
727
+ </TouchableOpacity>
728
+ </Stack.HeaderLeft>
729
+ </Stack.Header>
730
+ <View style={{ paddingVertical: 48, paddingHorizontal: 16, gap: 16 }}>
731
+ <Text style={{ fontSize: 22, fontWeight: "medium" }}>
732
+ Enter some text and try to go back
733
+ </Text>
734
+ <TextInput
735
+ value={input}
736
+ onChangeText={setInput}
737
+ placeholder="Enter some text"
738
+ onSubmitEditing={() => setInput("")}
739
+ />
740
+ <Button title="Submit" onPress={() => setInput("")} />
741
+ </View>
742
+ </Stack.Screen>
743
+ );
744
+ }
745
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rn-tools/navigation",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "main": "./src/index.ts",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -30,9 +30,11 @@
30
30
  "peerDependencies": {
31
31
  "react": "*",
32
32
  "react-native": "*",
33
+ "react-native-safe-area-context": "*",
33
34
  "react-native-screens": "*"
34
35
  },
35
36
  "dependencies": {
37
+ "path-to-regexp": "^6.2.2",
36
38
  "zustand": "^4.5.2"
37
39
  },
38
40
  "jest": {
package/src/contexts.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import * as React from 'react'
1
+ import * as React from "react";
2
2
 
3
3
  export let StackIdContext = React.createContext<string>("");
4
4
  export let ScreenIdContext = React.createContext<string>("");
@@ -0,0 +1,39 @@
1
+ import { match } from "path-to-regexp";
2
+ import * as React from "react";
3
+
4
+ export type DeepLinkHandler<T> = {
5
+ path: string;
6
+ handler: (params: T) => void;
7
+ };
8
+
9
+ function buildMatchers<T>(handlers: DeepLinkHandler<T>[]) {
10
+ return handlers.map(({ path, handler }) => {
11
+ let fn = match(path, { decode: decodeURIComponent });
12
+ return { fn, handler };
13
+ });
14
+ }
15
+
16
+ type DeepLinksProps<T> = {
17
+ path: string;
18
+ handlers: DeepLinkHandler<T>[];
19
+ children: React.ReactNode;
20
+ };
21
+
22
+ export function DeepLinks<T>({ path, handlers, children }: DeepLinksProps<T>) {
23
+ let matchers = React.useRef(buildMatchers(handlers));
24
+
25
+ React.useLayoutEffect(() => {
26
+ matchers.current = buildMatchers(handlers);
27
+ }, [handlers]);
28
+
29
+ React.useEffect(() => {
30
+ matchers.current.forEach(({ fn, handler }) => {
31
+ let match = fn(path);
32
+ if (match) {
33
+ return setImmediate(() => handler(match.params as T));
34
+ }
35
+ });
36
+ }, [path]);
37
+
38
+ return <>{children}</>;
39
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./navigation";
2
2
  export * from "./stack";
3
- export * from "./tabs";
3
+ export * from "./tabs";
4
+ export * from './deep-links'
@@ -103,6 +103,12 @@ type SetTabIndexAction = {
103
103
  tabId: string;
104
104
  };
105
105
 
106
+ type PopActiveTabAction = {
107
+ type: "POP_ACTIVE_TAB";
108
+ tabId: string;
109
+ index: number;
110
+ };
111
+
106
112
  type RegisterTabAction = {
107
113
  type: "REGISTER_TAB";
108
114
  depth: number;
@@ -126,7 +132,8 @@ type TabActions =
126
132
  | SetTabIndexAction
127
133
  | RegisterTabAction
128
134
  | UnregisterTabAction
129
- | TabBackAction;
135
+ | TabBackAction
136
+ | PopActiveTabAction;
130
137
 
131
138
  type SetDebugModeAction = {
132
139
  type: "SET_DEBUG_MODE";
@@ -365,37 +372,45 @@ export function reducer(
365
372
  }
366
373
 
367
374
  let nextState: NavigationState = Object.assign({}, state);
375
+ let currentIndex = tab.activeIndex;
368
376
  nextState.tabs.lookup[tabId] = Object.assign(
369
377
  {},
370
378
  {
371
379
  ...nextState.tabs.lookup[tabId],
372
380
  activeIndex: index,
373
- history: tab.history.filter((i) => i !== index).concat(index),
381
+ history: tab.history.filter((i) => i !== index).concat(currentIndex),
374
382
  }
375
383
  );
376
384
 
377
- if (tab.activeIndex === index) {
378
- let tabKey = serializeTabIndexKey(tabId, index);
379
- let stackIds = renderCharts.stacksByTabIndex[tabKey];
385
+ return nextState;
386
+ }
387
+
388
+ case "POP_ACTIVE_TAB": {
389
+ let { tabId, index } = action;
390
+ let { renderCharts } = context;
391
+
392
+ let tabKey = serializeTabIndexKey(tabId, index);
393
+ let stackIds = renderCharts.stacksByTabIndex[tabKey];
380
394
 
381
- if (stackIds?.length > 0) {
382
- stackIds.forEach((stackId) => {
383
- let stack = nextState.stacks.lookup[stackId];
384
- let screenIdsToRemove = stack.screens;
395
+ let nextState: NavigationState = Object.assign({}, state);
385
396
 
386
- let nextScreensLookup = Object.assign({}, nextState.screens.lookup);
397
+ if (stackIds?.length > 0) {
398
+ stackIds.forEach((stackId) => {
399
+ let stack = nextState.stacks.lookup[stackId];
400
+ let screenIdsToRemove = stack.screens;
387
401
 
388
- screenIdsToRemove.forEach((id) => {
389
- delete nextScreensLookup[id];
390
- });
402
+ let nextScreensLookup = Object.assign({}, nextState.screens.lookup);
391
403
 
392
- nextState.stacks.lookup[stackId].screens = [];
393
- nextState.screens.ids = nextState.screens.ids.filter(
394
- (id) => !screenIdsToRemove.includes(id)
395
- );
396
- nextState.screens.lookup = nextScreensLookup;
404
+ screenIdsToRemove.forEach((id) => {
405
+ delete nextScreensLookup[id];
397
406
  });
398
- }
407
+
408
+ nextState.stacks.lookup[stackId].screens = [];
409
+ nextState.screens.ids = nextState.screens.ids.filter(
410
+ (id) => !screenIdsToRemove.includes(id)
411
+ );
412
+ nextState.screens.lookup = nextScreensLookup;
413
+ });
399
414
  }
400
415
 
401
416
  return nextState;
@@ -442,10 +457,15 @@ export function reducer(
442
457
  let nextState: NavigationState = Object.assign({}, state);
443
458
 
444
459
  let tab = nextState.tabs.lookup[tabId];
445
- let last = tab.history.pop();
446
- if (last != null) {
447
- tab.activeIndex = last;
448
- }
460
+
461
+ let lastActiveIndex = tab.history[tab.history.length - 1];
462
+
463
+ nextState.tabs.lookup = Object.assign({}, nextState.tabs.lookup, {
464
+ [tabId]: Object.assign({}, nextState.tabs.lookup[tabId], {
465
+ activeIndex: lastActiveIndex,
466
+ history: tab.history.filter((i) => i !== lastActiveIndex),
467
+ }),
468
+ });
449
469
 
450
470
  return nextState;
451
471
  }
@@ -17,20 +17,18 @@ export function createNavigationStore() {
17
17
 
18
18
  let reducer = (state: NavigationState, action: NavigationAction) => {
19
19
  let nextState = navigationReducer(state, action, { renderCharts });
20
+ if (nextState.debugModeEnabled) {
21
+ console.debug(
22
+ `[@rntoolkit/navigation] action: ${action.type}`,
23
+ state,
24
+ nextState
25
+ );
26
+ }
20
27
  return { ...nextState };
21
28
  };
22
29
 
23
30
  let store = createStore(devtools(redux(reducer, initialState)));
24
31
 
25
- store.subscribe((state) => {
26
- if (state.debugModeEnabled) {
27
- console.debug("[@rntoolkit/navigation] state updated: ", {
28
- state,
29
- renderCharts,
30
- });
31
- }
32
- });
33
-
34
32
  return {
35
33
  store: store,
36
34
  dispatch: store.dispatch,
@@ -53,3 +51,8 @@ export function useNavigationDispatch() {
53
51
  let dispatch = React.useContext(NavigationDispatchContext);
54
52
  return dispatch;
55
53
  }
54
+
55
+ export function useGetNavigationStore() {
56
+ let context = React.useContext(NavigationStateContext);
57
+ return context.getState
58
+ }
@@ -13,7 +13,8 @@ import type { PushScreenOptions } from "./types";
13
13
  /**
14
14
  * Ideas:
15
15
  * - lifecycles / screen tracking
16
- * - testing - internal and jest plugin
16
+ * - testing guide
17
+ * - routing example -> fragments to ids
17
18
  */
18
19
 
19
20
  export function createNavigation() {
package/src/stack.tsx CHANGED
@@ -1,11 +1,26 @@
1
1
  import * as React from "react";
2
- import { BackHandler, StyleSheet, type ViewStyle } from "react-native";
2
+ import {
3
+ BackHandler,
4
+ PixelRatio,
5
+ Platform,
6
+ StyleSheet,
7
+ useWindowDimensions,
8
+ View,
9
+ type ImageProps,
10
+ type LayoutRectangle,
11
+ type ViewProps,
12
+ type ViewStyle,
13
+ } from "react-native";
3
14
  import {
4
15
  ScreenStackProps as RNScreenStackProps,
5
16
  Screen as RNScreen,
6
17
  ScreenProps as RNScreenProps,
7
18
  ScreenStackHeaderConfig as RNScreenStackHeaderConfig,
19
+ ScreenStackHeaderLeftView as RNScreenStackHeaderLeftView,
20
+ ScreenStackHeaderRightView as RNScreenStackHeaderRightView,
21
+ ScreenStackHeaderCenterView as RNScreenStackHeaderCenterView,
8
22
  ScreenStackHeaderConfigProps as RNScreenStackHeaderConfigProps,
23
+ ScreenStackHeaderBackButtonImage as RNScreenStackHeaderBackButtonImage,
9
24
  } from "react-native-screens";
10
25
  import ScreenStackNativeComponent from "react-native-screens/src/fabric/ScreenStackNativeComponent";
11
26
 
@@ -18,16 +33,18 @@ import {
18
33
  import { DEFAULT_SLOT_NAME } from "./navigation-reducer";
19
34
  import { useNavigationDispatch, useNavigationState } from "./navigation-store";
20
35
  import type { StackItem } from "./types";
21
- import { generateStackId } from "./utils";
36
+ import { generateStackId, useSafeAreaInsetsSafe } from "./utils";
22
37
 
23
38
  let StackIdContext = React.createContext<string>("");
24
39
  let ScreenIdContext = React.createContext<string>("");
25
40
 
26
- const RNScreenStack = React.memo(function RNScreenStack(
41
+ // Component returned from `react-native-screens` references `react-navigation` data structures in recent updates
42
+ // This is a workaround to make it work with our custom navigation
43
+ let RNScreenStack = React.memo(function RNScreenStack(
27
44
  props: RNScreenStackProps
28
45
  ) {
29
- const { children, gestureDetectorBridge, ...rest } = props;
30
- const ref = React.useRef(null);
46
+ let { children, gestureDetectorBridge, ...rest } = props;
47
+ let ref = React.useRef(null);
31
48
 
32
49
  React.useEffect(() => {
33
50
  if (gestureDetectorBridge) {
@@ -112,18 +129,13 @@ function StackRoot({ children, id }: StackRootProps) {
112
129
  );
113
130
  }
114
131
 
115
- function StackScreens({
116
- style: styleProp,
117
- ...props
118
- }: RNScreenStackProps) {
132
+ function StackScreens({ style: styleProp, ...props }: RNScreenStackProps) {
119
133
  let style = React.useMemo(
120
134
  () => styleProp || StyleSheet.absoluteFill,
121
135
  [styleProp]
122
136
  );
123
137
 
124
- return (
125
- <RNScreenStack {...props} style={style} />
126
- );
138
+ return <RNScreenStack {...props} style={style} />;
127
139
  }
128
140
 
129
141
  let defaultScreenStyle: ViewStyle = {
@@ -180,7 +192,7 @@ let StackScreen = React.memo(function StackScreen({
180
192
  <RNScreen
181
193
  {...props}
182
194
  style={style}
183
- // activityState={isActive ? 2 : 0}
195
+ activityState={isActive ? 2 : 0}
184
196
  gestureEnabled={gestureEnabled}
185
197
  onDismissed={onDismissed}
186
198
  >
@@ -224,9 +236,49 @@ let StackSlot = React.memo(function StackSlot({
224
236
  let StackScreenHeader = React.memo(function StackScreenHeader({
225
237
  ...props
226
238
  }: RNScreenStackHeaderConfigProps) {
227
- return <RNScreenStackHeaderConfig {...props} />;
239
+ let layout = useWindowDimensions();
240
+ let insets = useSafeAreaInsetsSafe();
241
+
242
+ let headerHeight = React.useMemo(() => {
243
+ if (Platform.OS === "android") {
244
+ return 0;
245
+ }
246
+
247
+ return getDefaultHeaderHeight(layout, false, insets.top);
248
+ }, [layout, insets]);
249
+
250
+ return (
251
+ <React.Fragment>
252
+ <RNScreenStackHeaderConfig {...props} />
253
+ <View style={{ height: headerHeight }} />
254
+ </React.Fragment>
255
+ );
228
256
  });
229
257
 
258
+ let StackScreenHeaderLeft = React.memo(function StackScreenHeaderLeft({
259
+ ...props
260
+ }: ViewProps) {
261
+ return <RNScreenStackHeaderLeftView {...props} />;
262
+ });
263
+
264
+ let StackScreenHeaderCenter = React.memo(function StackScreenHeaderCenter({
265
+ ...props
266
+ }: ViewProps) {
267
+ return <RNScreenStackHeaderCenterView {...props} />;
268
+ });
269
+
270
+ let StackScreenHeaderRight = React.memo(function StackScreenHeaderRight({
271
+ ...props
272
+ }: ViewProps) {
273
+ return <RNScreenStackHeaderRightView {...props} />;
274
+ });
275
+
276
+ let ScreenStackHeaderBackButtonImage = React.memo(
277
+ function ScreenStackHeaderBackButtonImage(props: ImageProps) {
278
+ return <RNScreenStackHeaderBackButtonImage {...props} />;
279
+ }
280
+ );
281
+
230
282
  type StackNavigatorProps = Omit<StackRootProps, "children"> & {
231
283
  rootScreen: React.ReactElement<unknown>;
232
284
  };
@@ -250,6 +302,54 @@ export let Stack = {
250
302
  Screens: StackScreens,
251
303
  Screen: StackScreen,
252
304
  Header: StackScreenHeader,
305
+ HeaderLeft: StackScreenHeaderLeft,
306
+ HeaderCenter: StackScreenHeaderCenter,
307
+ HeaderRight: StackScreenHeaderRight,
308
+ HeaderBackImage: ScreenStackHeaderBackButtonImage,
253
309
  Slot: StackSlot,
254
310
  Navigator: StackNavigator,
255
311
  };
312
+
313
+ // `onLayout` event does not return a value for the native header component
314
+ // This function is copied from react-navigation to get the default header heights
315
+ // Ref: https://github.com/react-navigation/react-navigation/blob/main/packages/elements/src/Header/getDefaultHeaderHeight.tsx#L5
316
+ function getDefaultHeaderHeight(
317
+ layout: Pick<LayoutRectangle, "width" | "height">,
318
+ // TODO - handle modal headers and substacks
319
+ modalPresentation: boolean,
320
+ topInset: number
321
+ ): number {
322
+ let headerHeight;
323
+
324
+ // On models with Dynamic Island the status bar height is smaller than the safe area top inset.
325
+ let hasDynamicIsland = Platform.OS === "ios" && topInset > 50;
326
+ let statusBarHeight = hasDynamicIsland
327
+ ? topInset - (5 + 1 / PixelRatio.get())
328
+ : topInset;
329
+
330
+ let isLandscape = layout.width > layout.height;
331
+
332
+ if (Platform.OS === "ios") {
333
+ if (Platform.isPad || Platform.isTV) {
334
+ if (modalPresentation) {
335
+ headerHeight = 56;
336
+ } else {
337
+ headerHeight = 50;
338
+ }
339
+ } else {
340
+ if (isLandscape) {
341
+ headerHeight = 32;
342
+ } else {
343
+ if (modalPresentation) {
344
+ headerHeight = 56;
345
+ } else {
346
+ headerHeight = 44;
347
+ }
348
+ }
349
+ }
350
+ } else {
351
+ headerHeight = 64;
352
+ }
353
+
354
+ return headerHeight + statusBarHeight;
355
+ }
package/src/tabs.tsx CHANGED
@@ -21,8 +21,12 @@ import {
21
21
  TabIdContext,
22
22
  TabScreenIndexContext,
23
23
  } from "./contexts";
24
- import { useNavigationDispatch, useNavigationState } from "./navigation-store";
25
- import { generateTabId } from "./utils";
24
+ import {
25
+ useGetNavigationStore,
26
+ useNavigationDispatch,
27
+ useNavigationState,
28
+ } from "./navigation-store";
29
+ import { generateTabId, useSafeAreaInsetsSafe } from "./utils";
26
30
 
27
31
  type TabsRootProps = {
28
32
  children: React.ReactNode;
@@ -121,6 +125,7 @@ let TabsScreen = React.memo(function TabsScreen({
121
125
 
122
126
  let tabId = React.useContext(TabIdContext);
123
127
  let tabs = useTabsInternal(tabId);
128
+ let getNavigationStore = useGetNavigationStore();
124
129
  let index = React.useContext(TabScreenIndexContext);
125
130
 
126
131
  let parentIsActive = React.useContext(ActiveContext);
@@ -129,6 +134,9 @@ let TabsScreen = React.memo(function TabsScreen({
129
134
 
130
135
  React.useEffect(() => {
131
136
  function backHandler() {
137
+ // Use getter to register the handler once on mount
138
+ // Prevents it from overriding child screen handlers
139
+ let tabs = getNavigationStore().tabs.lookup[tabId];
132
140
  if (tabs && tabs.history.length > 0) {
133
141
  dispatch({ type: "TAB_BACK", tabId });
134
142
  return true;
@@ -142,7 +150,7 @@ let TabsScreen = React.memo(function TabsScreen({
142
150
  return () => {
143
151
  BackHandler.removeEventListener("hardwareBackPress", backHandler);
144
152
  };
145
- }, [tabId, dispatch, tabs]);
153
+ }, [tabId, dispatch, getNavigationStore]);
146
154
 
147
155
  let style = React.useMemo(
148
156
  () => styleProp || StyleSheet.absoluteFill,
@@ -217,7 +225,11 @@ let TabsTab = React.memo(function TabsTab({
217
225
 
218
226
  let onPress: () => void = React.useCallback(() => {
219
227
  dispatch({ type: "SET_TAB_INDEX", tabId, index });
220
- }, [tabId, index, dispatch]);
228
+
229
+ if (isActive) {
230
+ dispatch({ type: "POP_ACTIVE_TAB", tabId, index });
231
+ }
232
+ }, [tabId, index, dispatch, isActive]);
221
233
 
222
234
  let style = React.useMemo(() => {
223
235
  let baseStyle = props.style || defaultTabStyle;
@@ -242,12 +254,37 @@ let TabsTab = React.memo(function TabsTab({
242
254
  });
243
255
 
244
256
 
257
+ export type TabNavigatorProps = Omit<TabsRootProps, "children"> & {
258
+ screens: TabNavigatorScreenOptions[];
259
+ tabbarPosition?: "top" | "bottom";
260
+ tabbarStyle?: ViewProps["style"];
261
+ };
262
+
263
+ export type TabNavigatorScreenOptions = {
264
+ key: string;
265
+ screen: React.ReactElement<unknown>;
266
+ tab: (props: { isActive: boolean; onPress: () => void }) => React.ReactNode;
267
+ };
268
+
245
269
  let TabNavigator = React.memo(function TabNavigator({
246
270
  screens,
247
271
  tabbarPosition = "bottom",
248
- tabbarStyle,
272
+ tabbarStyle: tabbarStyleProp,
249
273
  ...rootProps
250
274
  }: TabNavigatorProps) {
275
+ let insets = useSafeAreaInsetsSafe();
276
+
277
+ let tabbarStyle = React.useMemo(() => {
278
+ return [
279
+ defaultTabbarStyle,
280
+ {
281
+ paddingBottom: tabbarPosition === "bottom" ? insets.bottom : 0,
282
+ paddingTop: tabbarPosition === "top" ? insets.top : 0,
283
+ },
284
+ tabbarStyleProp,
285
+ ];
286
+ }, [tabbarPosition, tabbarStyleProp, insets]);
287
+
251
288
  return (
252
289
  <Tabs.Root {...rootProps}>
253
290
  {tabbarPosition === "top" && (
@@ -283,15 +320,3 @@ export let Tabs = {
283
320
  Tab: TabsTab,
284
321
  Navigator: TabNavigator,
285
322
  };
286
-
287
- export type TabNavigatorProps = Omit<TabsRootProps, "children"> & {
288
- screens: TabNavigatorScreenOptions[];
289
- tabbarPosition?: "top" | "bottom";
290
- tabbarStyle?: ViewProps["style"];
291
- };
292
-
293
- export type TabNavigatorScreenOptions = {
294
- key: string;
295
- screen: React.ReactElement<unknown>;
296
- tab: (props: { isActive: boolean; onPress: () => void }) => React.ReactNode;
297
- };
package/src/utils.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { Platform } from 'react-native';
2
+ import { useSafeAreaInsets, type EdgeInsets } from 'react-native-safe-area-context'
3
+
1
4
  export let generateStackId = createIdGenerator("stack");
2
5
  export let generateScreenId = createIdGenerator("screen");
3
6
  export let generateTabId = createIdGenerator("tab");
@@ -12,3 +15,27 @@ function createIdGenerator(name: string) {
12
15
 
13
16
  export let serializeTabIndexKey = (tabId: string, index: number) =>
14
17
  `${tabId}-${index}`;
18
+
19
+
20
+
21
+
22
+ let baseInsets: EdgeInsets = {
23
+ top: Platform.OS === "ios" ? 59 : 49,
24
+ bottom: Platform.OS === "ios" ? 34 : 0,
25
+ right: 0,
26
+ left: 0,
27
+ };
28
+
29
+ export function useSafeAreaInsetsSafe() {
30
+ let insets = baseInsets;
31
+
32
+ try {
33
+ // Linter thinks this is conditional but it seems fine
34
+ // eslint-disable-next-line
35
+ insets = useSafeAreaInsets();
36
+ } catch (error) {
37
+ console.log("useSafeAreaInsets is not available");
38
+ }
39
+
40
+ return insets;
41
+ }