Compare commits

...

31 Commits

Author SHA1 Message Date
Satyajit Sahoo
34c907ec0a chore: publish
- @react-navigation/stack@5.5.1
2020-06-08 11:21:27 +02:00
Satyajit Sahoo
1ae07af796 fix: make sure the header is on top of the view 2020-06-08 11:17:53 +02:00
Satyajit Sahoo
220af93db5 chore: publish
- @react-navigation/stack@5.5.0
2020-06-08 10:56:32 +02:00
Satyajit Sahoo
1f27e4b1f6 fix: ignore onOpen from route that wasn't closing
closes #8257
2020-06-08 10:48:04 +02:00
Satyajit Sahoo
9c06a92d09 fix: fix blank screen with animationEnabled: false & headerShown: false
closes #8391
2020-06-08 10:17:02 +02:00
Satyajit Sahoo
e0e0f79793 feat: automatically hide header in nested stacks 2020-06-08 08:14:34 +02:00
Jeroen Verfallie
c7e4bf94e6 fix: pass gestureRef to PanGestureHandlerNative (#8394)
In the current implementation the ref is unused, resulting in a constant `current: {null}` on the context.
2020-06-08 08:05:59 +02:00
Satyajit Sahoo
7024d4bb81 docs: fix comment about headerBacktitleVisible 2020-06-08 01:47:09 +02:00
Satyajit Sahoo
21f61d6eeb chore: publish
- @react-navigation/bottom-tabs@5.5.2
 - @react-navigation/compat@5.1.26
 - @react-navigation/core@5.10.0
 - @react-navigation/drawer@5.8.2
 - @react-navigation/material-bottom-tabs@5.2.10
 - @react-navigation/material-top-tabs@5.2.10
 - @react-navigation/native@5.5.1
 - @react-navigation/stack@5.4.2
2020-06-06 02:15:26 +02:00
Jean Regisser
8774ca97e1 fix: catch missing params when they are required in navigate (#8389)
There is a problem with the enforcement of required params in `navigation.navigate(...)` in TypeScript as described in https://github.com/react-navigation/react-navigation/issues/7936

@Miyou found a fix 🥳. All credits go to him.

I needed this so went ahead and submitted a PR and added a test to avoid it from breaking in the future.

However note that until this project switches to TypeScript 3.9 with support for [`@ts-expect-error`](https://devblogs.microsoft.com/typescript/announcing-typescript-3-9-beta/#ts-expect-error-comments), it can still break silently.

Before the change, this is how the test looks:
<img width="861" alt="Screenshot 2020-06-05 at 22 51 00" src="https://user-images.githubusercontent.com/57791/83923057-c53dc180-a781-11ea-8c35-36406a23a717.png">
As you can see it doesn't catch the missing params which are required on line 28.

After the change, all expected errors are raised:
<img width="812" alt="Screenshot 2020-06-05 at 22 51 59" src="https://user-images.githubusercontent.com/57791/83923413-80665a80-a782-11ea-8ff2-f5af3f4e1f32.png">

Let me know what you think.
2020-06-06 00:57:28 +02:00
Satyajit Sahoo
e653d55479 refactor: minor tweaks 2020-06-06 00:38:38 +02:00
Ashoat Tevosyan
78afbffe97 fix: relatively position float Header if !headerTransparent (#8285)
## Motivation

Right now `headerMode: float` renders an absolutely-positioned header. To offset the content appropriately, it then measures the height of the header and compensates with a margin. This approach unfortunately doesn't work well for animations.

Before             |  After
:-------------------------:|:-------------------------:
<img src="http://ashoat.com/jerky_absolute.gif" width="300" />  |  <img src="http://ashoat.com/smooth_relative.gif" width="300" />

## Approach

When rendering the header absolutely we want to render it above (after, in sibling order) the content. But when rendering it relatively we want to render it first (before, in sibling order).

The margin compensation code is no longer necessary so I removed it.

## Test plan

I used the `StackHeaderCustomization` example to make sure transitions between `headerTransparent` and `!headerTransparent` looked good. I added a custom (taller) header to test if height transitions looked good, and toggled `headerShown` to make sure that transitioned well too.

Would be open to any other suggestions of things to test!
2020-06-06 00:12:00 +02:00
Diego Mello
762cc44578 fix: typo on drawerPosition default props (#8357)
Fix a minor typo from `drawerPostion` to `drawerPosition` :)

Fix https://github.com/react-navigation/react-navigation/issues/8358
2020-06-05 23:48:10 +02:00
Satyajit Sahoo
c3bd349d77 fix: make sure the wildcard pattern catches nested unmatched routes 2020-06-05 23:03:37 +02:00
Satyajit Sahoo
5dcaf903f3 refactor: rework history stack integration (#8367)
The PR reworks history integration to better integrate with browser's history stack and supports nested navigation more reliably:

- On each navigation, save the navigation in memory and use it to reset the state when user presses back/forward
- Improve heuristic to determine if we should do a push, replace or back

This closes #8230, closes #8284 and closes #8344
2020-06-05 23:02:35 +02:00
Satyajit Sahoo
2d66ef93ec fix: only use the query params for focused route in path 2020-06-05 20:01:52 +02:00
Satyajit Sahoo
4fe72e3ce7 feat: add wildcard patterns for paths
Currently, if we don't have matching routes for a path, we'll reuse the path name for the route name. This doesn't produce an error, and renders the initial route in the navigator. However, the user doesn't have a way of handling this with the default configuration.

This PR adds support for a wildcard pattern ('*'). The wildcard pattern will be matched after all other patterns were matched and will always match unmatched screens. This allows the user to implement a 404 screen.

Example:

```js
{
  Home: '',
  Profile: 'user/:id',
  404: '*',
}
```

This config will return the `404` route for paths which didn't match `Home` or `Profile`, e.g. - `/test`

Closes #8019

Co-authored-by: Evan Bacon <baconbrix@gmail.com>
2020-06-05 17:13:00 +02:00
Satyajit Sahoo
ab1f79c096 fix: prevent state change being emitted unnecessarily 2020-06-01 21:32:08 +02:00
Satyajit Sahoo
9305bfa939 chore: try to fix yarn caching on gh actions 2020-05-27 20:12:33 +02:00
Satyajit Sahoo
0c3c450f5f chore: tweak SSR output 2020-05-27 19:55:37 +02:00
Satyajit Sahoo
7ac4c13d44 chore: publish
- @react-navigation/bottom-tabs@5.5.1
 - @react-navigation/compat@5.1.25
 - @react-navigation/core@5.9.0
 - @react-navigation/drawer@5.8.1
 - @react-navigation/material-bottom-tabs@5.2.9
 - @react-navigation/material-top-tabs@5.2.9
 - @react-navigation/native@5.5.0
 - @react-navigation/stack@5.4.1
2020-05-27 18:32:30 +02:00
Raviraj
a0b9f94120 refactor: remove unnecessary check for type of bottom tab bar label 2020-05-27 18:12:21 +02:00
Satyajit Sahoo
717dffdb81 chore: improve caching of yarn on gh actions 2020-05-27 13:31:50 +02:00
Satyajit Sahoo
9016ba00e3 chore: improve caching of yarn on circleci 2020-05-27 13:19:41 +02:00
Satyajit Sahoo
9d822b95a6 fix: fix type of style for various options 2020-05-26 17:33:50 +02:00
Satyajit Sahoo
52d5cb4179 chore: add an example for SSR (#8298)
<img width="740" alt="Screen Shot 2020-05-20 at 16 31 30" src="https://user-images.githubusercontent.com/1174278/82458770-673d8880-9ab7-11ea-81d3-8ac0c1e52705.png">
2020-05-26 16:07:47 +02:00
Satyajit Sahoo
af1722d1e9 fix: export types from /native 2020-05-26 14:11:48 +02:00
Satyajit Sahoo
0b1a718756 feat: add ref to get current options in ServerContainer (#8333)
User can pass a `ref` to the container to get current options, like they can with `NavigationContainer`:

```js
const ref = React.createRef();

const html = renderToString(
  <ServerContainer ref={ref}>
    <App />
  </ServerContainer>
);

ref.current.getCurrentOptions(); // Options for screen
```
2020-05-26 13:55:06 +02:00
Michał Osadnik
9ab29558d0 refactor: remove useless callback arg in useSubscription (#8332) 2020-05-26 13:21:51 +02:00
Bright Lee
00c23f2c9e fix: allow HeaderBackground's subViews to be touchable (#8317) 2020-05-25 15:50:24 +02:00
Satyajit Sahoo
68e750d5a6 feat: add a ServerContainer component for SSR (#8297)
When doing SSR, the app needs to be aware of request URL to render correct navigation state.
The `ServerContainer` component lets us pass the `location` object to use for SSR.
The shape of the `location` object matches the `location` object in the browser.

Usage:

```js
ReactDOM.renderToString(
  <ServerContainer location={{ pathname: req.path, search: req.search }}>
    <App />
  </ServerContainer>
);
```

Updated example: https://github.com/react-navigation/react-navigation/pull/8298
2020-05-24 14:28:16 +02:00
75 changed files with 2917 additions and 642 deletions

View File

@@ -22,13 +22,14 @@ jobs:
- attach_project - attach_project
- restore_cache: - restore_cache:
keys: keys:
- v2-dependencies-{{ checksum "yarn.lock" }} - yarn-packages-v1-{{ .Branch }}-{{ checksum "yarn.lock" }}
- v2-dependencies- - yarn-packages-v1-{{ .Branch }}-
- yarn-packages-v1-
- run: - run:
name: Install project dependencies name: Install project dependencies
command: yarn install --frozen-lockfile command: yarn install --frozen-lockfile
- save_cache: - save_cache:
key: v2-dependencies-{{ checksum "yarn.lock" }} key: yarn-packages-v1-{{ .Branch }}-{{ checksum "yarn.lock" }}
paths: ~/.cache/yarn paths: ~/.cache/yarn
- persist_to_workspace: - persist_to_workspace:
root: . root: .

View File

@@ -23,20 +23,16 @@ jobs:
expo-password: ${{ secrets.EXPO_CLI_PASSWORD }} expo-password: ${{ secrets.EXPO_CLI_PASSWORD }}
expo-cache: true expo-cache: true
- name: Get yarn cache - name: Restore yarn cache
id: yarn-cache id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)" uses: actions/cache@master
- name: Check yarn cache
uses: actions/cache@v1
with: with:
path: ${{ steps.yarn-cache.outputs.dir }} path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies - name: Install dependencies
run: yarn if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Publish Expo app - name: Publish Expo app
working-directory: ./example working-directory: ./example

View File

@@ -25,19 +25,16 @@ jobs:
expo-password: ${{ secrets.EXPO_CLI_PASSWORD }} expo-password: ${{ secrets.EXPO_CLI_PASSWORD }}
expo-cache: true expo-cache: true
- name: Get yarn cache - name: Restore yarn cache
id: yarn-cache id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)" uses: actions/cache@master
- uses: actions/cache@v1
with: with:
path: ${{ steps.yarn-cache.outputs.dir }} path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies - name: Install dependencies
run: yarn if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Publish Expo app - name: Publish Expo app
working-directory: ./example working-directory: ./example

View File

@@ -1,6 +1,10 @@
import 'react-native-gesture-handler'; import 'react-native-gesture-handler';
import { registerRootComponent } from 'expo'; import { registerRootComponent } from 'expo';
import { Asset } from 'expo-asset';
import { Assets as StackAssets } from '@react-navigation/stack';
import App from './src/index'; import App from './src/index';
Asset.loadAsync(StackAssets);
registerRootComponent(App); registerRootComponent(App);

View File

@@ -0,0 +1,13 @@
import fetch from 'node-fetch';
import cheerio from 'cheerio';
const server = 'http://localhost:3275';
it('renders the home page', async () => {
const res = await fetch(server);
const html = await res.text();
const $ = cheerio.load(html);
expect($('title').text()).toBe('Examples');
});

View File

@@ -1,8 +1,16 @@
import { setup } from 'jest-dev-server'; import { setup } from 'jest-dev-server';
export default async function () { export default async function () {
await setup({ await setup([
command: 'yarn serve -l 3579 web-build', {
port: 3579, command: 'yarn serve -l 3579 web-build',
}); launchTimeout: 50000,
port: 3579,
},
{
command: 'yarn server',
launchTimeout: 50000,
port: 3275,
},
]);
} }

View File

@@ -9,6 +9,7 @@
"native": "react-native start", "native": "react-native start",
"android": "react-native run-android", "android": "react-native run-android",
"ios": "react-native run-ios", "ios": "react-native run-ios",
"server": "nodemon -e '.js,.ts,.tsx' --exec \"babel-node -i '/node_modules[/\\](?react-native)/' -x '.web.tsx,.web.ts,.web.js,.tsx,.ts,.js' --config-file ./server/babel.config.js server\"",
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
@@ -18,6 +19,7 @@
"expo": "^37.0.8", "expo": "^37.0.8",
"expo-asset": "~8.1.3", "expo-asset": "~8.1.3",
"expo-blur": "~8.1.0", "expo-blur": "~8.1.0",
"koa": "^2.12.0",
"react": "~16.9.0", "react": "~16.9.0",
"react-dom": "~16.9.0", "react-dom": "~16.9.0",
"react-native": "~0.61.5", "react-native": "~0.61.5",
@@ -29,17 +31,28 @@
"react-native-screens": "^2.7.0", "react-native-screens": "^2.7.0",
"react-native-tab-view": "2.14.0", "react-native-tab-view": "2.14.0",
"react-native-unimodules": "~0.9.1", "react-native-unimodules": "~0.9.1",
"react-native-vector-icons": "^6.6.0",
"react-native-web": "^0.11.7" "react-native-web": "^0.11.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/node": "^7.8.7",
"@expo/webpack-config": "^0.11.19", "@expo/webpack-config": "^0.11.19",
"@types/cheerio": "^0.22.18",
"@types/jest-dev-server": "^4.2.0", "@types/jest-dev-server": "^4.2.0",
"@types/koa": "^2.11.3",
"@types/node-fetch": "^2.5.7",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-dom": "^16.9.8",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",
"babel-plugin-module-resolver": "^4.0.0",
"babel-preset-expo": "^8.1.0", "babel-preset-expo": "^8.1.0",
"cheerio": "^1.0.0-rc.3",
"expo-cli": "^3.20.1", "expo-cli": "^3.20.1",
"jest": "^26.0.1", "jest": "^26.0.1",
"jest-dev-server": "^4.4.0", "jest-dev-server": "^4.4.0",
"mock-require-assets": "^0.0.1",
"node-fetch": "^2.6.0",
"nodemon": "^2.0.4",
"playwright": "^0.14.0", "playwright": "^0.14.0",
"serve": "^11.3.0", "serve": "^11.3.0",
"typescript": "^3.8.3" "typescript": "^3.8.3"

View File

@@ -0,0 +1,40 @@
const path = require('path');
const fs = require('fs');
const packages = path.resolve(__dirname, '..', '..', 'packages');
const alias = Object.fromEntries(
fs
.readdirSync(packages)
.filter((name) => !name.startsWith('.'))
.map((name) => [
`@react-navigation/${name}`,
path.resolve(
packages,
name,
require(`../../packages/${name}/package.json`).source
),
])
);
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-flow',
'@babel/preset-typescript',
'@babel/preset-react',
],
plugins: [
'@babel/plugin-proposal-class-properties',
[
'module-resolver',
{
root: ['..'],
alias: {
'react-native': 'react-native-web',
...alias,
},
},
],
],
};

54
example/server/index.tsx Normal file
View File

@@ -0,0 +1,54 @@
import './resolve-hooks';
import Koa from 'koa';
import * as React from 'react';
import ReactDOMServer from 'react-dom/server';
import { AppRegistry } from 'react-native-web';
import { ServerContainer, ServerContainerRef } from '@react-navigation/native';
import App from '../src/index';
AppRegistry.registerComponent('App', () => App);
const PORT = process.env.PORT || 3275;
const app = new Koa();
app.use(async (ctx) => {
const { element, getStyleElement } = AppRegistry.getApplication('App');
const ref = React.createRef<ServerContainerRef>();
const html = ReactDOMServer.renderToString(
<ServerContainer
ref={ref}
location={{ pathname: ctx.path, search: ctx.search }}
>
{element}
</ServerContainer>
);
const css = ReactDOMServer.renderToStaticMarkup(getStyleElement());
const document = `
<!DOCTYPE html>
<html style="height: 100%">
<meta charset="utf-8">
<meta httpEquiv="X-UA-Compatible" content="IE=edge">
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover"
>
${css}
<title>${ref.current?.getCurrentOptions()?.title}</title>
<body style="min-height: 100%">
<div id="root" style="display: flex; min-height: 100vh">
${html}
</div>
`;
ctx.body = document;
});
app.listen(PORT, () => {
console.log(`Running at http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,12 @@
import 'mock-require-assets';
import Module from 'module';
// We need to make sure that .web.xx extensions are resolved before .xx
// @ts-ignore
Module._extensions = Object.fromEntries(
// @ts-ignore
Object.entries(Module._extensions).sort((a, b) => {
return b[0].split('.').length - a[0].split('.').length;
})
);

View File

@@ -0,0 +1,11 @@
import RNRestart from 'react-native-restart';
import { Updates } from 'expo';
export function restartApp() {
// @ts-ignore
if (global.Expo) {
Updates.reloadFromCache();
} else {
RNRestart.Restart();
}
}

1
example/src/Restart.tsx Normal file
View File

@@ -0,0 +1 @@
export function restartApp() {}

View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native'; import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { Button } from 'react-native-paper'; import { Button } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { import {
createBottomTabNavigator, createBottomTabNavigator,
BottomTabNavigationProp, BottomTabNavigationProp,
@@ -71,13 +71,12 @@ export default function BottomTabsScreen() {
> >
<BottomTabs.Screen <BottomTabs.Screen
name="Article" name="Article"
component={SimpleStackScreen}
options={{ options={{
title: 'Article', title: 'Article',
tabBarIcon: getTabBarIcon('file-document-box'), tabBarIcon: getTabBarIcon('file-document-box'),
}} }}
> />
{(props) => <SimpleStackScreen {...props} headerMode="none" />}
</BottomTabs.Screen>
<BottomTabs.Screen <BottomTabs.Screen
name="Chat" name="Chat"
component={Chat} component={Chat}

View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet } from 'react-native';
import { Title, Button } from 'react-native-paper'; import { Title, Button } from 'react-native-paper';
import { Feather } from '@expo/vector-icons'; import Feather from 'react-native-vector-icons/Feather';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
type BottomTabParams = { type BottomTabParams = {

View File

@@ -22,14 +22,13 @@ export default function MaterialBottomTabsScreen() {
<MaterialBottomTabs.Navigator barStyle={styles.tabBar}> <MaterialBottomTabs.Navigator barStyle={styles.tabBar}>
<MaterialBottomTabs.Screen <MaterialBottomTabs.Screen
name="Article" name="Article"
component={SimpleStackScreen}
options={{ options={{
tabBarLabel: 'Article', tabBarLabel: 'Article',
tabBarIcon: 'file-document-box', tabBarIcon: 'file-document-box',
tabBarColor: '#C9E7F8', tabBarColor: '#C9E7F8',
}} }}
> />
{(props) => <SimpleStackScreen {...props} headerMode="none" />}
</MaterialBottomTabs.Screen>
<MaterialBottomTabs.Screen <MaterialBottomTabs.Screen
name="Chat" name="Chat"
component={Chat} component={Chat}

View File

@@ -91,7 +91,6 @@ export default function SimpleStackScreen({ navigation, options }: Props) {
return ( return (
<ModalPresentationStack.Navigator <ModalPresentationStack.Navigator
mode="modal" mode="modal"
headerMode="screen"
screenOptions={({ route, navigation }) => ({ screenOptions={({ route, navigation }) => ({
...TransitionPresets.ModalPresentationIOS, ...TransitionPresets.ModalPresentationIOS,
cardOverlayEnabled: true, cardOverlayEnabled: true,

View File

@@ -0,0 +1,40 @@
import { StackNavigationProp } from '@react-navigation/stack';
import * as React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Button } from 'react-native-paper';
const NotFoundScreen = ({
navigation,
}: {
navigation: StackNavigationProp<{ Home: undefined }>;
}) => {
return (
<View style={styles.container}>
<Text style={styles.title}>404 Not Found</Text>
<Button
mode="contained"
onPress={() => navigation.navigate('Home')}
style={styles.button}
>
Go to home
</Button>
</View>
);
};
export default NotFoundScreen;
const styles = StyleSheet.create({
title: {
fontSize: 36,
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 8,
},
button: {
margin: 24,
},
});

View File

@@ -111,17 +111,17 @@ const AlbumsScreen = ({
const SimpleStack = createStackNavigator<SimpleStackParams>(); const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & { type Props = {
navigation: StackNavigationProp<ParamListBase>; navigation: StackNavigationProp<ParamListBase>;
}; };
export default function SimpleStackScreen({ navigation, ...rest }: Props) { export default function SimpleStackScreen({ navigation }: Props) {
navigation.setOptions({ navigation.setOptions({
headerShown: false, headerShown: false,
}); });
return ( return (
<SimpleStack.Navigator {...rest}> <SimpleStack.Navigator>
<SimpleStack.Screen <SimpleStack.Screen
name="Article" name="Article"
component={ArticleScreen} component={ArticleScreen}

View File

@@ -1,15 +1,24 @@
import * as React from 'react'; import * as React from 'react';
import { View, StyleSheet, ScrollView, Alert, Platform } from 'react-native'; import {
Animated,
View,
StyleSheet,
ScrollView,
Alert,
Platform,
} from 'react-native';
import { Button, Appbar } from 'react-native-paper'; import { Button, Appbar } from 'react-native-paper';
import { BlurView } from 'expo-blur'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useTheme, RouteProp, ParamListBase } from '@react-navigation/native';
import { RouteProp, ParamListBase } from '@react-navigation/native';
import { import {
createStackNavigator, createStackNavigator,
StackNavigationProp, StackNavigationProp,
HeaderBackground, HeaderBackground,
useHeaderHeight, useHeaderHeight,
Header,
StackHeaderProps,
} from '@react-navigation/stack'; } from '@react-navigation/stack';
import BlurView from '../Shared/BlurView';
import Article from '../Shared/Article'; import Article from '../Shared/Article';
import Albums from '../Shared/Albums'; import Albums from '../Shared/Albums';
@@ -91,11 +100,32 @@ type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>; navigation: StackNavigationProp<ParamListBase>;
}; };
function CustomHeader(props: StackHeaderProps) {
const { current, next } = props.scene.progress;
const progress = Animated.add(current, next || 0);
const opacity = progress.interpolate({
inputRange: [0, 1, 2],
outputRange: [0, 1, 0],
});
return (
<>
<Header {...props} />
<Animated.Text style={[styles.banner, { opacity }]}>
Why hello there, pardner!
</Animated.Text>
</>
);
}
export default function SimpleStackScreen({ navigation, ...rest }: Props) { export default function SimpleStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({ navigation.setOptions({
headerShown: false, headerShown: false,
}); });
const { colors, dark } = useTheme();
return ( return (
<SimpleStack.Navigator {...rest}> <SimpleStack.Navigator {...rest}>
<SimpleStack.Screen <SimpleStack.Screen
@@ -103,6 +133,7 @@ export default function SimpleStackScreen({ navigation, ...rest }: Props) {
component={ArticleScreen} component={ArticleScreen}
options={({ route }) => ({ options={({ route }) => ({
title: `Article by ${route.params?.author}`, title: `Article by ${route.params?.author}`,
header: CustomHeader,
headerTintColor: '#fff', headerTintColor: '#fff',
headerStyle: { backgroundColor: '#ff005d' }, headerStyle: { backgroundColor: '#ff005d' },
headerBackTitleVisible: false, headerBackTitleVisible: false,
@@ -138,9 +169,15 @@ export default function SimpleStackScreen({ navigation, ...rest }: Props) {
headerBackTitle: 'Back', headerBackTitle: 'Back',
headerTransparent: true, headerTransparent: true,
headerBackground: () => ( headerBackground: () => (
<HeaderBackground style={{ backgroundColor: 'transparent' }}> <HeaderBackground
style={{
backgroundColor: 'transparent',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
}}
>
<BlurView <BlurView
tint="light" tint={dark ? 'dark' : 'light'}
intensity={75} intensity={75}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
@@ -160,4 +197,10 @@ const styles = StyleSheet.create({
button: { button: {
margin: 8, margin: 8,
}, },
banner: {
textAlign: 'center',
color: 'tomato',
backgroundColor: 'papayawhip',
padding: 4,
},
}); });

View File

@@ -0,0 +1,3 @@
import { BlurView } from 'expo-blur';
export default BlurView;

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import { View, ViewProps } from 'react-native';
type Props = ViewProps & {
tint: 'light' | 'dark';
intensity: number;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function BlurView({ tint, intensity, ...rest }: Props) {
return <View {...rest} />;
}

View File

@@ -11,10 +11,7 @@ import {
} from 'react-native'; } from 'react-native';
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { enableScreens } from 'react-native-screens'; import { enableScreens } from 'react-native-screens';
import RNRestart from 'react-native-restart'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { Updates } from 'expo';
import { Asset } from 'expo-asset';
import { MaterialIcons } from '@expo/vector-icons';
import { import {
Provider as PaperProvider, Provider as PaperProvider,
DefaultTheme as PaperLightTheme, DefaultTheme as PaperLightTheme,
@@ -29,6 +26,7 @@ import {
NavigationContainer, NavigationContainer,
DefaultTheme, DefaultTheme,
DarkTheme, DarkTheme,
PathConfig,
} from '@react-navigation/native'; } from '@react-navigation/native';
import { import {
createDrawerNavigator, createDrawerNavigator,
@@ -36,11 +34,11 @@ import {
} from '@react-navigation/drawer'; } from '@react-navigation/drawer';
import { import {
createStackNavigator, createStackNavigator,
Assets as StackAssets,
StackNavigationProp, StackNavigationProp,
HeaderStyleInterpolators, HeaderStyleInterpolators,
} from '@react-navigation/stack'; } from '@react-navigation/stack';
import { restartApp } from './Restart';
import AsyncStorage from './AsyncStorage'; import AsyncStorage from './AsyncStorage';
import LinkingPrefixes from './LinkingPrefixes'; import LinkingPrefixes from './LinkingPrefixes';
import SettingsItem from './Shared/SettingsItem'; import SettingsItem from './Shared/SettingsItem';
@@ -51,6 +49,7 @@ import StackHeaderCustomization from './Screens/StackHeaderCustomization';
import BottomTabs from './Screens/BottomTabs'; import BottomTabs from './Screens/BottomTabs';
import MaterialTopTabsScreen from './Screens/MaterialTopTabs'; import MaterialTopTabsScreen from './Screens/MaterialTopTabs';
import MaterialBottomTabs from './Screens/MaterialBottomTabs'; import MaterialBottomTabs from './Screens/MaterialBottomTabs';
import NotFound from './Screens/NotFound';
import DynamicTabs from './Screens/DynamicTabs'; import DynamicTabs from './Screens/DynamicTabs';
import AuthFlow from './Screens/AuthFlow'; import AuthFlow from './Screens/AuthFlow';
import CompatAPI from './Screens/CompatAPI'; import CompatAPI from './Screens/CompatAPI';
@@ -71,6 +70,7 @@ type RootDrawerParamList = {
type RootStackParamList = { type RootStackParamList = {
Home: undefined; Home: undefined;
NotFound: undefined;
} & { } & {
[P in keyof typeof SCREENS]: undefined; [P in keyof typeof SCREENS]: undefined;
}; };
@@ -126,12 +126,10 @@ const Stack = createStackNavigator<RootStackParamList>();
const NAVIGATION_PERSISTENCE_KEY = 'NAVIGATION_STATE'; const NAVIGATION_PERSISTENCE_KEY = 'NAVIGATION_STATE';
const THEME_PERSISTENCE_KEY = 'THEME_TYPE'; const THEME_PERSISTENCE_KEY = 'THEME_TYPE';
Asset.loadAsync(StackAssets);
export default function App() { export default function App() {
const [theme, setTheme] = React.useState(DefaultTheme); const [theme, setTheme] = React.useState(DefaultTheme);
const [isReady, setIsReady] = React.useState(false); const [isReady, setIsReady] = React.useState(Platform.OS === 'web');
const [initialState, setInitialState] = React.useState< const [initialState, setInitialState] = React.useState<
InitialState | undefined InitialState | undefined
>(); >();
@@ -226,35 +224,45 @@ export default function App() {
Root: { Root: {
path: '', path: '',
initialRouteName: 'Home', initialRouteName: 'Home',
screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>( screens: Object.keys(SCREENS).reduce<PathConfig>(
(acc, name) => { (acc, name) => {
// Convert screen names such as SimpleStack to kebab case (simple-stack) // Convert screen names such as SimpleStack to kebab case (simple-stack)
acc[name] = name const path = name
.replace(/([A-Z]+)/g, '-$1') .replace(/([A-Z]+)/g, '-$1')
.replace(/^-/, '') .replace(/^-/, '')
.toLowerCase(); .toLowerCase();
acc[name] = {
path,
screens: {
Article: {
path: 'article/:author?',
parse: {
author: (author) =>
author.charAt(0).toUpperCase() +
author.slice(1).replace(/-/g, ' '),
},
stringify: {
author: (author: string) =>
author.toLowerCase().replace(/\s/g, '-'),
},
},
Albums: 'music',
Chat: 'chat',
Contacts: 'people',
NewsFeed: 'feed',
Dialog: 'dialog',
},
};
return acc; return acc;
}, },
{ Home: '' } {
Home: '',
NotFound: '*',
}
), ),
}, },
Article: {
path: 'article/:author?',
parse: {
author: (author) =>
author.charAt(0).toUpperCase() +
author.slice(1).replace(/-/g, ' '),
},
stringify: {
author: (author: string) =>
author.toLowerCase().replace(/\s/g, '-'),
},
},
Albums: 'music',
Chat: 'chat',
Contacts: 'people',
NewsFeed: 'feed',
}, },
}} }}
fallback={<Text>Loading</Text>} fallback={<Text>Loading</Text>}
@@ -307,12 +315,7 @@ export default function App() {
value={I18nManager.isRTL} value={I18nManager.isRTL}
onValueChange={() => { onValueChange={() => {
I18nManager.forceRTL(!I18nManager.isRTL); I18nManager.forceRTL(!I18nManager.isRTL);
// @ts-ignore restartApp();
if (global.Expo) {
Updates.reloadFromCache();
} else {
RNRestart.Restart();
}
}} }}
/> />
<Divider /> <Divider />
@@ -342,6 +345,11 @@ export default function App() {
</ScrollView> </ScrollView>
)} )}
</Stack.Screen> </Stack.Screen>
<Stack.Screen
name="NotFound"
component={NotFound}
options={{ title: 'Oops!' }}
/>
{(Object.keys(SCREENS) as (keyof typeof SCREENS)[]).map( {(Object.keys(SCREENS) as (keyof typeof SCREENS)[]).map(
(name) => ( (name) => (
<Stack.Screen <Stack.Screen

16
example/types/react-native-web.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
declare module 'react-native-web' {
export const AppRegistry: {
registerComponent(
name: string,
callback: () => React.ComponentType<any>
): void;
getApplication(
name: string,
options?: { initialProps: object }
): {
element: React.ReactElement;
getStyleElement(): React.ReactElement;
};
};
}

View File

@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.5.2](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.5.1...@react-navigation/bottom-tabs@5.5.2) (2020-06-06)
**Note:** Version bump only for package @react-navigation/bottom-tabs
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.5.0...@react-navigation/bottom-tabs@5.5.1) (2020-05-27)
### Bug Fixes
* fix type of style for various options ([9d822b9](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/commit/9d822b95a6df797e2e63e481573e64ea7d0f9386))
# [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.4.7...@react-navigation/bottom-tabs@5.5.0) (2020-05-23) # [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.4.7...@react-navigation/bottom-tabs@5.5.0) (2020-05-23)

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/bottom-tabs", "name": "@react-navigation/bottom-tabs",
"description": "Bottom tab navigator following iOS design guidelines", "description": "Bottom tab navigator following iOS design guidelines",
"version": "5.5.0", "version": "5.5.2",
"keywords": [ "keywords": [
"react-native-component", "react-native-component",
"react-component", "react-component",
@@ -37,7 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { import {
Animated,
TouchableWithoutFeedbackProps, TouchableWithoutFeedbackProps,
StyleProp, StyleProp,
TextStyle, TextStyle,
@@ -197,7 +198,7 @@ export type BottomTabBarOptions = {
/** /**
* Style object for the tab bar container. * Style object for the tab bar container.
*/ */
style?: StyleProp<ViewStyle>; style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
}; };
export type BottomTabBarProps = BottomTabBarOptions & { export type BottomTabBarProps = BottomTabBarOptions & {

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { import {
View, View,
Text,
TouchableWithoutFeedback, TouchableWithoutFeedback,
Animated,
StyleSheet, StyleSheet,
Platform, Platform,
StyleProp, StyleProp,
@@ -191,7 +191,7 @@ export default function BottomTabBarItem({
if (typeof label === 'string') { if (typeof label === 'string') {
return ( return (
<Animated.Text <Text
numberOfLines={1} numberOfLines={1}
style={[ style={[
styles.label, styles.label,
@@ -202,14 +202,10 @@ export default function BottomTabBarItem({
allowFontScaling={allowFontScaling} allowFontScaling={allowFontScaling}
> >
{label} {label}
</Animated.Text> </Text>
); );
} }
if (typeof label === 'string') {
return label;
}
return label({ focused, color }); return label({ focused, color });
}; };

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.1.26](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.25...@react-navigation/compat@5.1.26) (2020-06-06)
**Note:** Version bump only for package @react-navigation/compat
## [5.1.25](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.24...@react-navigation/compat@5.1.25) (2020-05-27)
**Note:** Version bump only for package @react-navigation/compat
## [5.1.24](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.23...@react-navigation/compat@5.1.24) (2020-05-23) ## [5.1.24](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.23...@react-navigation/compat@5.1.24) (2020-05-23)
**Note:** Version bump only for package @react-navigation/compat **Note:** Version bump only for package @react-navigation/compat

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/compat", "name": "@react-navigation/compat",
"description": "Compatibility layer to write navigator definitions in static configuration format", "description": "Compatibility layer to write navigator definitions in static configuration format",
"version": "5.1.24", "version": "5.1.26",
"license": "MIT", "license": "MIT",
"repository": "https://github.com/react-navigation/react-navigation/tree/master/packages/compat", "repository": "https://github.com/react-navigation/react-navigation/tree/master/packages/compat",
"bugs": { "bugs": {
@@ -28,7 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"react": "~16.9.0", "react": "~16.9.0",
"typescript": "^3.8.3" "typescript": "^3.8.3"

View File

@@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [5.10.0](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.9.0...@react-navigation/core@5.10.0) (2020-06-06)
### Bug Fixes
* catch missing params when they are required in navigate ([#8389](https://github.com/react-navigation/react-navigation/tree/master/packages/core/issues/8389)) ([8774ca9](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/8774ca97e1da91e97677ecd816c85f66af296b93))
* make sure the wildcard pattern catches nested unmatched routes ([c3bd349](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/c3bd349d77688011c9c55027edd66c6f39de2ade))
* only use the query params for focused route in path ([2d66ef9](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/2d66ef93ec9923a452415c482c40e7c6b769917c))
* prevent state change being emitted unnecessarily ([ab1f79c](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/ab1f79c096e94475a4da1acf1c850d04fb1bc4cf))
### Features
* add wildcard patterns for paths ([4fe72e3](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/4fe72e3ce7bae9120d04e490401f3bad58ebdf5c)), closes [#8019](https://github.com/react-navigation/react-navigation/tree/master/packages/core/issues/8019)
# [5.9.0](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.8.2...@react-navigation/core@5.9.0) (2020-05-27)
### Features
* add ref to get current options in `ServerContainer` ([#8333](https://github.com/react-navigation/react-navigation/tree/master/packages/core/issues/8333)) ([0b1a718](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/0b1a718756e208d84b20e45ca56004332308ad54))
## [5.8.2](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.8.1...@react-navigation/core@5.8.2) (2020-05-23) ## [5.8.2](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.8.1...@react-navigation/core@5.8.2) (2020-05-23)
**Note:** Version bump only for package @react-navigation/core **Note:** Version bump only for package @react-navigation/core

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/core", "name": "@react-navigation/core",
"description": "Core utilities for building navigators", "description": "Core utilities for building navigators",
"version": "5.8.2", "version": "5.10.0",
"keywords": [ "keywords": [
"react", "react",
"react-native", "react-native",

View File

@@ -237,6 +237,12 @@ const BaseNavigationContainer = React.forwardRef(
[getKey, getState, setKey, setState, state, addOptionsGetter] [getKey, getState, setKey, setState, state, addOptionsGetter]
); );
const onStateChangeRef = React.useRef(onStateChange);
React.useEffect(() => {
onStateChangeRef.current = onStateChange;
});
React.useEffect(() => { React.useEffect(() => {
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
if ( if (
@@ -263,12 +269,12 @@ const BaseNavigationContainer = React.forwardRef(
trackState(getRootState); trackState(getRootState);
} }
if (!isFirstMountRef.current && onStateChange) { if (!isFirstMountRef.current && onStateChangeRef.current) {
onStateChange(getRootState()); onStateChangeRef.current(getRootState());
} }
isFirstMountRef.current = false; isFirstMountRef.current = false;
}, [onStateChange, trackState, getRootState, emitter, state]); }, [trackState, getRootState, emitter, state]);
return ( return (
<ScheduleUpdateContext.Provider value={scheduleContext}> <ScheduleUpdateContext.Provider value={scheduleContext}>

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
/**
* Context which holds the values for the current navigation tree.
* Intended for use in SSR. This is not safe to use on the client.
*/
const CurrentRenderContext = React.createContext<
{ options?: object } | undefined
>(undefined);
export default CurrentRenderContext;

View File

@@ -1265,3 +1265,175 @@ it('replaces undefined query params', () => {
expect(getPathFromState(state, config)).toBe(path); expect(getPathFromState(state, config)).toBe(path);
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path); expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path);
}); });
it('matches wildcard patterns at root', () => {
const path = '/test/bar/42/whatever';
const config = {
404: '*',
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
},
},
},
};
const state = {
routes: [{ name: '404' }],
};
expect(getPathFromState(state, config)).toBe('/404');
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
});
it('matches wildcard patterns at nested level', () => {
const path = '/bar/42/whatever/baz/initt';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: '*',
},
},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
params: { id: '42' },
state: {
routes: [{ name: '404' }],
},
},
],
},
},
],
};
expect(getPathFromState(state, config)).toBe('/bar/42/404');
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(
'/bar/42/404'
);
});
it('matches wildcard patterns at nested level with exact', () => {
const path = '/whatever';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: {
path: '*',
exact: true,
},
},
},
Baz: {},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
state: {
routes: [{ name: '404' }],
},
},
],
},
},
],
};
expect(getPathFromState(state, config)).toBe('/404');
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
});
it('tries to match wildcard patterns at the end', () => {
const path = '/bar/42/test';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: '*',
Test: 'test',
},
},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
params: { id: '42' },
state: {
routes: [{ name: 'Test' }],
},
},
],
},
},
],
};
expect(getPathFromState(state, config)).toBe(path);
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path);
});
it('uses nearest parent wildcard match for unmatched paths', () => {
const path = '/bar/42/baz/test';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
Baz: 'baz',
},
},
404: '*',
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [{ name: '404' }],
},
},
],
};
expect(getPathFromState(state, config)).toBe('/404');
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
});

View File

@@ -1883,3 +1883,183 @@ it('ignores extra slashes in the pattern', () => {
state state
); );
}); });
it('matches wildcard patterns at root', () => {
const path = '/test/bar/42/whatever';
const config = {
404: '*',
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
},
},
},
};
const state = {
routes: [{ name: '404' }],
};
expect(getStateFromPath(path, config)).toEqual(state);
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
state
);
});
it('matches wildcard patterns at nested level', () => {
const path = '/bar/42/whatever/baz/initt';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: '*',
},
},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
params: { id: '42' },
state: {
routes: [{ name: '404' }],
},
},
],
},
},
],
};
expect(getStateFromPath(path, config)).toEqual(state);
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
state
);
});
it('matches wildcard patterns at nested level with exact', () => {
const path = '/whatever';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: {
path: '*',
exact: true,
},
},
},
Baz: {},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
state: {
routes: [{ name: '404' }],
},
},
],
},
},
],
};
expect(getStateFromPath(path, config)).toEqual(state);
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
state
);
});
it('tries to match wildcard patterns at the end', () => {
const path = '/bar/42/test';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
404: '*',
Test: 'test',
},
},
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [
{
name: 'Bar',
params: { id: '42' },
state: {
routes: [{ name: 'Test' }],
},
},
],
},
},
],
};
expect(getStateFromPath(path, config)).toEqual(state);
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
state
);
});
it('uses nearest parent wildcard match for unmatched paths', () => {
const path = '/bar/42/baz/test';
const config = {
Foo: {
screens: {
Bar: {
path: '/bar/:id/',
screens: {
Baz: 'baz',
},
},
404: '*',
},
},
};
const state = {
routes: [
{
name: 'Foo',
state: {
routes: [{ name: '404' }],
},
},
],
};
expect(getStateFromPath(path, config)).toEqual(state);
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
state
);
});

View File

@@ -18,6 +18,19 @@ type ConfigItem = {
screens?: Record<string, ConfigItem>; screens?: Record<string, ConfigItem>;
}; };
const getActiveRoute = (state: State): { name: string; params?: object } => {
const route =
typeof state.index === 'number'
? state.routes[state.index]
: state.routes[state.routes.length - 1];
if (route.state) {
return getActiveRoute(route.state);
}
return route;
};
/** /**
* Utility to serialize a navigation state object to a path string. * Utility to serialize a navigation state object to a path string.
* *
@@ -69,7 +82,8 @@ export default function getPathFromState(
let pattern: string | undefined; let pattern: string | undefined;
let currentParams: Record<string, any> = { ...route.params }; let focusedParams: Record<string, any> | undefined;
let focusedRoute = getActiveRoute(state);
let currentOptions = configs; let currentOptions = configs;
// Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined // Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined
@@ -85,7 +99,7 @@ export default function getPathFromState(
if (route.params) { if (route.params) {
const stringify = currentOptions[route.name]?.stringify; const stringify = currentOptions[route.name]?.stringify;
currentParams = fromEntries( const currentParams = fromEntries(
Object.entries(route.params).map(([key, value]) => [ Object.entries(route.params).map(([key, value]) => [
key, key,
stringify?.[key] ? stringify[key](value) : String(value), stringify?.[key] ? stringify[key](value) : String(value),
@@ -95,6 +109,26 @@ export default function getPathFromState(
if (pattern) { if (pattern) {
Object.assign(allParams, currentParams); Object.assign(allParams, currentParams);
} }
if (focusedRoute === route) {
// If this is the focused route, keep the params for later use
// We save it here since it's been stringified already
focusedParams = { ...currentParams };
pattern
?.split('/')
.filter((p) => p.startsWith(':'))
// eslint-disable-next-line no-loop-func
.forEach((p) => {
const name = getParamName(p);
// Remove the params present in the pattern since we'll only use the rest for query string
if (focusedParams) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete focusedParams[name];
}
});
}
} }
// If there is no `screens` property or no nested state, we return pattern // If there is no `screens` property or no nested state, we return pattern
@@ -128,18 +162,19 @@ export default function getPathFromState(
path += pattern path += pattern
.split('/') .split('/')
.map((p) => { .map((p) => {
const name = p.replace(/^:/, '').replace(/\?$/, ''); const name = getParamName(p);
// We don't know what to show for wildcard patterns
// Showing the route name seems ok, though whatever we show here will be incorrect
// Since the page doesn't actually exist
if (p === '*') {
return route.name;
}
// If the path has a pattern for a param, put the param in the path // If the path has a pattern for a param, put the param in the path
if (p.startsWith(':')) { if (p.startsWith(':')) {
const value = allParams[name]; const value = allParams[name];
// Remove the used value from the params object since we'll use the rest for query string
if (currentParams) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete currentParams[name];
}
if (value === undefined && p.endsWith('?')) { if (value === undefined && p.endsWith('?')) {
// Optional params without value assigned in route.params should be ignored // Optional params without value assigned in route.params should be ignored
return ''; return '';
@@ -155,17 +190,21 @@ export default function getPathFromState(
path += encodeURIComponent(route.name); path += encodeURIComponent(route.name);
} }
if (!focusedParams) {
focusedParams = focusedRoute.params;
}
if (route.state) { if (route.state) {
path += '/'; path += '/';
} else if (currentParams) { } else if (focusedParams) {
for (let param in currentParams) { for (let param in focusedParams) {
if (currentParams[param] === 'undefined') { if (focusedParams[param] === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete currentParams[param]; delete focusedParams[param];
} }
} }
const query = queryString.stringify(currentParams); const query = queryString.stringify(focusedParams);
if (query) { if (query) {
path += `?${query}`; path += `?${query}`;
@@ -189,6 +228,9 @@ const fromEntries = <K extends string, V>(entries: (readonly [K, V])[]) =>
return acc; return acc;
}, {} as Record<K, V>); }, {} as Record<K, V>);
const getParamName = (pattern: string) =>
pattern.replace(/^:/, '').replace(/\?$/, '');
const joinPaths = (...paths: string[]): string => const joinPaths = (...paths: string[]): string =>
([] as string[]) ([] as string[])
.concat(...paths.map((p) => p.split('/'))) .concat(...paths.map((p) => p.split('/')))

View File

@@ -59,11 +59,46 @@ export default function getStateFromPath(
createNormalizedConfigs(key, options, [], initialRoutes) createNormalizedConfigs(key, options, [], initialRoutes)
) )
) )
.sort( .sort((a, b) => {
(a, b) => // Sort config so that:
// Sort configs so the most exhaustive is always first to be chosen // - the most exhaustive ones are always at the beginning
b.pattern.split('/').length - a.pattern.split('/').length // - patterns with wildcard are always at the end
);
// If one of the patterns starts with the other, it's more exhaustive
// So move it up
if (a.pattern.startsWith(b.pattern)) {
return 1;
}
if (b.pattern.startsWith(a.pattern)) {
return 1;
}
const aParts = a.pattern.split('/');
const bParts = b.pattern.split('/');
const aWildcardIndex = aParts.indexOf('*');
const bWildcardIndex = bParts.indexOf('*');
// If only one of the patterns has a wildcard, move it down in the list
if (aWildcardIndex === -1 && bWildcardIndex !== -1) {
return -1;
}
if (aWildcardIndex !== -1 && bWildcardIndex === -1) {
return 1;
}
if (aWildcardIndex === bWildcardIndex) {
// If `b` has more `/`, it's more exhaustive
// So we move it up in the list
return bParts.length - aParts.length;
}
// If the wildcard appears later in the pattern (has higher index), it's more specific
// So we move it up in the list
return bWildcardIndex - aWildcardIndex;
});
let remaining = path let remaining = path
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones .replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
@@ -104,41 +139,37 @@ export default function getStateFromPath(
let result: PartialState<NavigationState> | undefined; let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined; let current: PartialState<NavigationState> | undefined;
// We try to match the paths in 2 passes
// In first pass, we match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const { routeNames, allParams, remainingPath } = matchAgainstConfigs(
remaining,
configs.map((c) => ({
...c,
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
}))
);
if (routeNames !== undefined) {
// This will always be empty if full path matched
remaining = remainingPath;
current = createNestedStateObject(
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
result = current;
}
// In second pass, we divide the path into segments and match piece by piece
// This preserves the old behaviour, but we should remove it in next major
while (remaining) { while (remaining) {
let routeNames: string[] | undefined; let { routeNames, allParams, remainingPath } = matchAgainstConfigs(
let allParams: Record<string, any> | undefined; remaining,
configs
);
// Go through all configs, and see if the next path segment matches our regex remaining = remainingPath;
for (const config of configs) {
if (!config.regex) {
continue;
}
const match = remaining.match(config.regex);
// If our regex matches, we need to extract params from the path
if (match) {
routeNames = [...config.routeNames];
const paramPatterns = config.pattern
.split('/')
.filter((p) => p.startsWith(':'));
if (paramPatterns.length) {
allParams = paramPatterns.reduce<Record<string, any>>((acc, p, i) => {
const value = match![(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result
acc[p] = value;
return acc;
}, {});
}
remaining = remaining.replace(match[1], '');
break;
}
}
// If we hadn't matched any segments earlier, use the path as route name // If we hadn't matched any segments earlier, use the path as route name
if (routeNames === undefined) { if (routeNames === undefined) {
@@ -150,43 +181,7 @@ export default function getStateFromPath(
} }
const state = createNestedStateObject( const state = createNestedStateObject(
routeNames.map((name) => { createRouteObjects(configs, routeNames, allParams),
const config = configs.find((c) => c.screen === name);
let params: object | undefined;
if (allParams && config?.path) {
const pattern = config.path;
if (pattern) {
const paramPatterns = pattern
.split('/')
.filter((p) => p.startsWith(':'));
if (paramPatterns.length) {
params = paramPatterns.reduce<Record<string, any>>((acc, p) => {
const key = p.replace(/^:/, '').replace(/\?$/, '');
const value = allParams![p];
if (value) {
acc[key] =
config.parse && config.parse[key]
? config.parse[key](value)
: value;
}
return acc;
}, {});
}
}
}
if (params && Object.keys(params).length) {
return { name, params };
}
return { name };
}),
initialRoutes initialRoutes
); );
@@ -229,6 +224,46 @@ const joinPaths = (...paths: string[]): string =>
.filter(Boolean) .filter(Boolean)
.join('/'); .join('/');
const matchAgainstConfigs = (remaining: string, configs: RouteConfig[]) => {
let routeNames: string[] | undefined;
let allParams: Record<string, any> | undefined;
let remainingPath = remaining;
// Go through all configs, and see if the next path segment matches our regex
for (const config of configs) {
if (!config.regex) {
continue;
}
const match = remainingPath.match(config.regex);
// If our regex matches, we need to extract params from the path
if (match) {
routeNames = [...config.routeNames];
const paramPatterns = config.pattern
.split('/')
.filter((p) => p.startsWith(':'));
if (paramPatterns.length) {
allParams = paramPatterns.reduce<Record<string, any>>((acc, p, i) => {
const value = match![(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result
acc[p] = value;
return acc;
}, {});
}
remainingPath = remainingPath.replace(match[1], '');
break;
}
}
return { routeNames, allParams, remainingPath };
};
const createNormalizedConfigs = ( const createNormalizedConfigs = (
screen: string, screen: string,
routeConfig: PathConfig, routeConfig: PathConfig,
@@ -311,7 +346,7 @@ const createConfigItem = (
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`; return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
} }
return `${escape(it)}\\/`; return `${it === '*' ? '.*' : escape(it)}\\/`;
}) })
.join('')})` .join('')})`
) )
@@ -433,6 +468,49 @@ const createNestedStateObject = (
return state; return state;
}; };
const createRouteObjects = (
configs: RouteConfig[],
routeNames: string[],
allParams?: Record<string, any>
) =>
routeNames.map((name) => {
const config = configs.find((c) => c.screen === name);
let params: object | undefined;
if (allParams && config?.path) {
const pattern = config.path;
if (pattern) {
const paramPatterns = pattern
.split('/')
.filter((p) => p.startsWith(':'));
if (paramPatterns.length) {
params = paramPatterns.reduce<Record<string, any>>((acc, p) => {
const key = p.replace(/^:/, '').replace(/\?$/, '');
const value = allParams![p];
if (value) {
acc[key] =
config.parse && config.parse[key]
? config.parse[key](value)
: value;
}
return acc;
}, {});
}
}
}
if (params && Object.keys(params).length) {
return { name, params };
}
return { name };
});
const findFocusedRoute = (state: InitialState) => { const findFocusedRoute = (state: InitialState) => {
let current: InitialState | undefined = state; let current: InitialState | undefined = state;

View File

@@ -7,6 +7,8 @@ export { default as NavigationHelpersContext } from './NavigationHelpersContext'
export { default as NavigationContext } from './NavigationContext'; export { default as NavigationContext } from './NavigationContext';
export { default as NavigationRouteContext } from './NavigationRouteContext'; export { default as NavigationRouteContext } from './NavigationRouteContext';
export { default as CurrentRenderContext } from './CurrentRenderContext';
export { default as useNavigationBuilder } from './useNavigationBuilder'; export { default as useNavigationBuilder } from './useNavigationBuilder';
export { default as useNavigation } from './useNavigation'; export { default as useNavigation } from './useNavigation';
export { default as useRoute } from './useRoute'; export { default as useRoute } from './useRoute';

View File

@@ -0,0 +1,7 @@
/**
* Compare two arrays with primitive values as the content.
* We need to make sure that both values and order match.
*/
export default function isArrayEqual(a: any[], b: any[]) {
return a.length === b.length && a.every((it, index) => it === b[index]);
}

View File

@@ -152,7 +152,7 @@ type NavigationHelpersCommon<
* @param [params] Params object for the route. * @param [params] Params object for the route.
*/ */
navigate<RouteName extends keyof ParamList>( navigate<RouteName extends keyof ParamList>(
...args: ParamList[RouteName] extends undefined | any ...args: undefined extends ParamList[RouteName]
? [RouteName] | [RouteName, ParamList[RouteName]] ? [RouteName] | [RouteName, ParamList[RouteName]]
: [RouteName, ParamList[RouteName]] : [RouteName, ParamList[RouteName]]
): void; ): void;

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { NavigationState, ParamListBase } from '@react-navigation/routers';
import CurrentRenderContext from './CurrentRenderContext';
import { Descriptor, NavigationHelpers } from './types';
type Options = {
state: NavigationState;
navigation: NavigationHelpers<ParamListBase>;
descriptors: {
[key: string]: Descriptor<ParamListBase, string, NavigationState, object>;
};
};
/**
* Write the current options, so that server renderer can get current values
* Mutating values like this is not safe in async mode, but it doesn't apply to SSR
*/
export default function useCurrentRender({
state,
navigation,
descriptors,
}: Options) {
const current = React.useContext(CurrentRenderContext);
if (current && navigation.isFocused()) {
current.options = descriptors[state.routes[state.index].key].options;
}
}

View File

@@ -12,14 +12,10 @@ export default function useIsFocused(): boolean {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const getCurrentValue = React.useCallback(navigation.isFocused, [navigation]); const getCurrentValue = React.useCallback(navigation.isFocused, [navigation]);
const subscribe = React.useCallback( const subscribe = React.useCallback(
(callback: (value: boolean) => void) => { (callback: () => void) => {
const unsubscribeFocus = navigation.addListener('focus', () => const unsubscribeFocus = navigation.addListener('focus', callback);
callback(true)
);
const unsubscribeBlur = navigation.addListener('blur', () => const unsubscribeBlur = navigation.addListener('blur', callback);
callback(false)
);
return () => { return () => {
unsubscribeFocus(); unsubscribeFocus();

View File

@@ -33,6 +33,8 @@ import {
import useStateGetters from './useStateGetters'; import useStateGetters from './useStateGetters';
import useOnGetState from './useOnGetState'; import useOnGetState from './useOnGetState';
import useScheduleUpdate from './useScheduleUpdate'; import useScheduleUpdate from './useScheduleUpdate';
import useCurrentRender from './useCurrentRender';
import isArrayEqual from './isArrayEqual';
// This is to make TypeScript compiler happy // This is to make TypeScript compiler happy
// eslint-disable-next-line babel/no-unused-expressions // eslint-disable-next-line babel/no-unused-expressions
@@ -47,13 +49,6 @@ type NavigatorRoute = {
}; };
}; };
/**
* Compare two arrays with primitive values as the content.
* We need to make sure that both values and order match.
*/
const isArrayEqual = (a: any[], b: any[]) =>
a.length === b.length && a.every((it, index) => it === b[index]);
/** /**
* Extract route config object from React children elements. * Extract route config object from React children elements.
* *
@@ -498,6 +493,12 @@ export default function useNavigationBuilder<
emitter, emitter,
}); });
useCurrentRender({
state,
navigation,
descriptors,
});
return { return {
state, state,
navigation, navigation,

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { NavigationState } from '@react-navigation/routers'; import { NavigationState } from '@react-navigation/routers';
import NavigationBuilderContext from './NavigationBuilderContext'; import NavigationBuilderContext from './NavigationBuilderContext';
import NavigationRouteContext from './NavigationRouteContext'; import NavigationRouteContext from './NavigationRouteContext';
import isArrayEqual from './isArrayEqual';
export default function useOnGetState({ export default function useOnGetState({
getStateForRoute, getStateForRoute,
@@ -16,13 +17,23 @@ export default function useOnGetState({
const getRehydratedState = React.useCallback(() => { const getRehydratedState = React.useCallback(() => {
const state = getState(); const state = getState();
return {
...state, // Avoid returning new route objects if we don't need to
routes: state.routes.map((route) => ({ const routes = state.routes.map((route) => {
...route, const childState = getStateForRoute(route.key);
state: getStateForRoute(route.key),
})), if (route.state === childState) {
}; return route;
}
return { ...route, state: childState };
});
if (isArrayEqual(state.routes, routes)) {
return state;
}
return { ...state, routes };
}, [getState, getStateForRoute]); }, [getState, getStateForRoute]);
React.useEffect(() => { React.useEffect(() => {

View File

@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.8.2](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.8.1...@react-navigation/drawer@5.8.2) (2020-06-06)
### Bug Fixes
* typo on drawerPosition default props ([#8357](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/issues/8357)) ([762cc44](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/commit/762cc4457842182189eeac84aedbb88169452e1e))
## [5.8.1](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.8.0...@react-navigation/drawer@5.8.1) (2020-05-27)
**Note:** Version bump only for package @react-navigation/drawer
# [5.8.0](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.7.7...@react-navigation/drawer@5.8.0) (2020-05-23) # [5.8.0](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.7.7...@react-navigation/drawer@5.8.0) (2020-05-23)

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/drawer", "name": "@react-navigation/drawer",
"description": "Drawer navigator component with animated transitions and gesturess", "description": "Drawer navigator component with animated transitions and gesturess",
"version": "5.8.0", "version": "5.8.2",
"keywords": [ "keywords": [
"react-native-component", "react-native-component",
"react-component", "react-component",
@@ -42,7 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",
"del-cli": "^3.0.0", "del-cli": "^3.0.0",

View File

@@ -100,7 +100,7 @@ type Props = {
export default class DrawerView extends React.Component<Props> { export default class DrawerView extends React.Component<Props> {
static defaultProps = { static defaultProps = {
drawerPostion: I18nManager.isRTL ? 'left' : 'right', drawerPosition: I18nManager.isRTL ? 'left' : 'right',
drawerType: 'front', drawerType: 'front',
gestureEnabled: true, gestureEnabled: true,
swipeEnabled: Platform.OS !== 'web', swipeEnabled: Platform.OS !== 'web',

View File

@@ -238,7 +238,6 @@ export default function DrawerView({
renderDrawerContent={renderNavigationView} renderDrawerContent={renderNavigationView}
renderSceneContent={renderContent} renderSceneContent={renderContent}
keyboardDismissMode={keyboardDismissMode} keyboardDismissMode={keyboardDismissMode}
drawerPostion={drawerPosition}
dimensions={dimensions} dimensions={dimensions}
/> />
</DrawerOpenContext.Provider> </DrawerOpenContext.Provider>

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.2.10](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.9...@react-navigation/material-bottom-tabs@5.2.10) (2020-06-06)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.8...@react-navigation/material-bottom-tabs@5.2.9) (2020-05-27)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
## [5.2.8](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.7...@react-navigation/material-bottom-tabs@5.2.8) (2020-05-23) ## [5.2.8](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.7...@react-navigation/material-bottom-tabs@5.2.8) (2020-05-23)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs **Note:** Version bump only for package @react-navigation/material-bottom-tabs

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/material-bottom-tabs", "name": "@react-navigation/material-bottom-tabs",
"description": "Integration for bottom navigation component from react-native-paper", "description": "Integration for bottom navigation component from react-native-paper",
"version": "5.2.8", "version": "5.2.10",
"keywords": [ "keywords": [
"react-native-component", "react-native-component",
"react-component", "react-component",
@@ -38,7 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",
"@types/react-native-vector-icons": "^6.4.5", "@types/react-native-vector-icons": "^6.4.5",

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.2.10](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.9...@react-navigation/material-top-tabs@5.2.10) (2020-06-06)
**Note:** Version bump only for package @react-navigation/material-top-tabs
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.8...@react-navigation/material-top-tabs@5.2.9) (2020-05-27)
**Note:** Version bump only for package @react-navigation/material-top-tabs
## [5.2.8](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.7...@react-navigation/material-top-tabs@5.2.8) (2020-05-23) ## [5.2.8](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.7...@react-navigation/material-top-tabs@5.2.8) (2020-05-23)
**Note:** Version bump only for package @react-navigation/material-top-tabs **Note:** Version bump only for package @react-navigation/material-top-tabs

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/material-top-tabs", "name": "@react-navigation/material-top-tabs",
"description": "Integration for the animated tab view component from react-native-tab-view", "description": "Integration for the animated tab view component from react-native-tab-view",
"version": "5.2.8", "version": "5.2.10",
"keywords": [ "keywords": [
"react-native-component", "react-native-component",
"react-component", "react-component",
@@ -41,7 +41,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",
"del-cli": "^3.0.0", "del-cli": "^3.0.0",

View File

@@ -3,6 +3,31 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.5.0...@react-navigation/native@5.5.1) (2020-06-06)
**Note:** Version bump only for package @react-navigation/native
# [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.4.3...@react-navigation/native@5.5.0) (2020-05-27)
### Bug Fixes
* export types from /native ([af1722d](https://github.com/react-navigation/react-navigation/tree/master/packages/native/commit/af1722d1e915f3ec234df202f74c4b4c631472c7))
### Features
* add a `ServerContainer` component for SSR ([#8297](https://github.com/react-navigation/react-navigation/tree/master/packages/native/issues/8297)) ([68e750d](https://github.com/react-navigation/react-navigation/tree/master/packages/native/commit/68e750d5a6d198a2f5bdb86ba631de0a27732943))
* add ref to get current options in `ServerContainer` ([#8333](https://github.com/react-navigation/react-navigation/tree/master/packages/native/issues/8333)) ([0b1a718](https://github.com/react-navigation/react-navigation/tree/master/packages/native/commit/0b1a718756e208d84b20e45ca56004332308ad54))
## [5.4.3](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.4.2...@react-navigation/native@5.4.3) (2020-05-23) ## [5.4.3](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.4.2...@react-navigation/native@5.4.3) (2020-05-23)
**Note:** Version bump only for package @react-navigation/native **Note:** Version bump only for package @react-navigation/native

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/native", "name": "@react-navigation/native",
"description": "React Native integration for React Navigation", "description": "React Native integration for React Navigation",
"version": "5.4.3", "version": "5.5.1",
"keywords": [ "keywords": [
"react-native", "react-native",
"react-navigation", "react-navigation",
@@ -33,14 +33,17 @@
"clean": "del lib" "clean": "del lib"
}, },
"dependencies": { "dependencies": {
"@react-navigation/core": "^5.8.2" "@react-navigation/core": "^5.10.0",
"nanoid": "^3.1.9"
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-dom": "^16.9.8",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",
"del-cli": "^3.0.0", "del-cli": "^3.0.0",
"react": "~16.9.0", "react": "~16.9.0",
"react-dom": "^16.13.1",
"react-native": "~0.61.5", "react-native": "~0.61.5",
"react-native-testing-library": "^1.13.2", "react-native-testing-library": "^1.13.2",
"typescript": "^3.8.3" "typescript": "^3.8.3"

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { CurrentRenderContext } from '@react-navigation/core';
import ServerContext, { ServerContextType } from './ServerContext';
import { ServerContainerRef } from './types';
type Props = ServerContextType & {
children: React.ReactNode;
};
/**
* Container component for server rendering.
*
* @param props.location Location object to base the initial URL for SSR.
* @param props.children Child elements to render the content.
* @param props.ref Ref object which contains helper methods.
*/
export default React.forwardRef(function ServerContainer(
{ children, location }: Props,
ref: React.Ref<ServerContainerRef>
) {
React.useEffect(() => {
console.error(
"'ServerContainer' should only be used on the server with 'react-dom/server' for SSR."
);
}, []);
const current: { options?: object } = {};
if (ref) {
const value = {
getCurrentOptions() {
return current.options;
},
};
// We write to the `ref` during render instead of `React.useImperativeHandle`
// This is because `useImperativeHandle` will update the ref after 'commit',
// and there's no 'commit' phase during SSR.
// Mutating ref during render is unsafe in concurrent mode, but we don't care about it for SSR.
if (typeof ref === 'function') {
ref(value);
} else {
// @ts-ignore: the TS types are incorrect and say that ref.current is readonly
ref.current = value;
}
}
return (
<ServerContext.Provider value={{ location }}>
<CurrentRenderContext.Provider value={current}>
{children}
</CurrentRenderContext.Provider>
</ServerContext.Provider>
);
});

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
export type ServerContextType = {
location?: {
pathname: string;
search: string;
};
};
const ServerContext = React.createContext<ServerContextType | undefined>(
undefined
);
export default ServerContext;

View File

@@ -0,0 +1,69 @@
const location = new URL('', 'http://example.com');
let listeners: (() => void)[] = [];
let entries = [{ state: null, href: location.href }];
let index = 0;
let currentState: any = null;
const history = {
get state() {
return currentState;
},
pushState(state: any, _: string, path: string) {
Object.assign(location, new URL(path, location.origin));
currentState = state;
entries = entries.slice(0, index + 1);
entries.push({ state, href: location.href });
index = entries.length - 1;
},
replaceState(state: any, _: string, path: string) {
Object.assign(location, new URL(path, location.origin));
currentState = state;
entries[index] = { state, href: location.href };
},
go(n: number) {
setTimeout(() => {
if (
(n > 0 && n < entries.length - index) ||
(n < 0 && Math.abs(n) <= index)
) {
index += n;
Object.assign(location, new URL(entries[index].href));
listeners.forEach((cb) => cb);
}
}, 0);
},
back() {
this.go(-1);
},
forward() {
this.go(1);
},
};
const addEventListener = (type: 'popstate', listener: () => void) => {
if (type === 'popstate') {
listeners.push(listener);
}
};
const removeEventListener = (type: 'popstate', listener: () => void) => {
if (type === 'popstate') {
listeners = listeners.filter((cb) => cb !== listener);
}
};
export default {
location,
history,
addEventListener,
removeEventListener,
};

View File

@@ -0,0 +1,149 @@
import * as React from 'react';
import {
useNavigationBuilder,
createNavigatorFactory,
StackRouter,
TabRouter,
NavigationHelpersContext,
NavigationContainerRef,
} from '@react-navigation/core';
import { act, render } from 'react-native-testing-library';
import NavigationContainer from '../NavigationContainer';
import window from '../__mocks__/window';
// @ts-ignore
global.window = window;
// We want to use the web version of useLinking
jest.mock('../useLinking', () => require('../useLinking.tsx').default);
it('integrates with the history API', () => {
jest.useFakeTimers();
const createStackNavigator = createNavigatorFactory((props: any) => {
const { navigation, state, descriptors } = useNavigationBuilder(
StackRouter,
props
);
return (
<NavigationHelpersContext.Provider value={navigation}>
{state.routes.map((route, i) => (
<div key={route.key} aria-current={state.index === i || undefined}>
{descriptors[route.key].render()}
</div>
))}
</NavigationHelpersContext.Provider>
);
});
const createTabNavigator = createNavigatorFactory((props: any) => {
const { navigation, state, descriptors } = useNavigationBuilder(
TabRouter,
props
);
return (
<NavigationHelpersContext.Provider value={navigation}>
{state.routes.map((route, i) => (
<div key={route.key} aria-current={state.index === i || undefined}>
{descriptors[route.key].render()}
</div>
))}
</NavigationHelpersContext.Provider>
);
});
const Stack = createStackNavigator();
const Tab = createTabNavigator();
const TestScreen = ({ route }: any): any =>
`${route.name} ${JSON.stringify(route.params)}`;
const linking = {
prefixes: [],
config: {
Home: {
path: '',
initialRouteName: 'Feed',
screens: {
Profile: ':user',
Settings: 'edit',
Updates: 'updates',
Feed: 'feed',
},
},
Chat: 'chat',
},
};
const navigation = React.createRef<NavigationContainerRef>();
render(
<NavigationContainer ref={navigation} linking={linking}>
<Tab.Navigator>
<Tab.Screen name="Home">
{() => (
<Stack.Navigator initialRouteName="Feed">
<Stack.Screen name="Profile" component={TestScreen} />
<Stack.Screen name="Settings" component={TestScreen} />
<Stack.Screen name="Feed" component={TestScreen} />
<Stack.Screen name="Updates" component={TestScreen} />
</Stack.Navigator>
)}
</Tab.Screen>
<Tab.Screen name="Chat" component={TestScreen} />
</Tab.Navigator>
</NavigationContainer>
);
expect(window.location.pathname).toBe('/feed');
act(() => navigation.current?.navigate('Profile', { user: 'jane' }));
expect(window.location.pathname).toBe('/jane');
act(() => navigation.current?.navigate('Updates'));
expect(window.location.pathname).toBe('/updates');
act(() => navigation.current?.goBack());
jest.runAllTimers();
expect(window.location.pathname).toBe('/jane');
act(() => {
window.history.back();
jest.runAllTimers();
});
expect(window.location.pathname).toBe('/feed');
act(() => {
window.history.forward();
jest.runAllTimers();
});
expect(window.location.pathname).toBe('/jane');
act(() => navigation.current?.navigate('Settings'));
expect(window.location.pathname).toBe('/edit');
act(() => {
window.history.go(-2);
jest.runAllTimers();
});
expect(window.location.pathname).toBe('/feed');
act(() => navigation.current?.navigate('Settings'));
act(() => navigation.current?.navigate('Chat'));
expect(window.location.pathname).toBe('/chat');
act(() => navigation.current?.navigate('Home'));
expect(window.location.pathname).toBe('/edit');
});

View File

@@ -0,0 +1,185 @@
import * as React from 'react';
import {
useNavigationBuilder,
createNavigatorFactory,
StackRouter,
TabRouter,
NavigationHelpersContext,
} from '@react-navigation/core';
import { renderToString } from 'react-dom/server';
import NavigationContainer from '../NavigationContainer';
import ServerContainer from '../ServerContainer';
import { ServerContainerRef } from '../types';
// @ts-ignore
global.window = global;
window.addEventListener = () => {};
window.removeEventListener = () => {};
// We want to use the web version of useLinking
jest.mock('../useLinking', () => require('../useLinking.tsx').default);
it('renders correct state with location', () => {
const createStackNavigator = createNavigatorFactory((props: any) => {
const { navigation, state, descriptors } = useNavigationBuilder(
StackRouter,
props
);
return (
<NavigationHelpersContext.Provider value={navigation}>
{state.routes.map((route) => (
<div key={route.key}>{descriptors[route.key].render()}</div>
))}
</NavigationHelpersContext.Provider>
);
});
const Stack = createStackNavigator();
const TestScreen = ({ route }: any): any =>
`${route.name} ${JSON.stringify(route.params)}`;
const NestedStack = () => {
return (
<Stack.Navigator initialRouteName="Feed">
<Stack.Screen name="Profile" component={TestScreen} />
<Stack.Screen name="Settings" component={TestScreen} />
<Stack.Screen name="Feed" component={TestScreen} />
<Stack.Screen name="Updates" component={TestScreen} />
</Stack.Navigator>
);
};
const element = (
<NavigationContainer
linking={{
prefixes: [],
config: {
Home: {
initialRouteName: 'Profile',
screens: {
Settings: {
path: ':user/edit',
},
Updates: {
path: ':user/updates',
},
},
},
},
}}
>
<Stack.Navigator>
<Stack.Screen name="Home" component={NestedStack} />
<Stack.Screen name="Chat" component={TestScreen} />
</Stack.Navigator>
</NavigationContainer>
);
// @ts-ignore
window.location = { pathname: '/jane/edit', search: '' };
const client = renderToString(element);
expect(client).toMatchInlineSnapshot(
`"<div><div>Profile undefined</div><div>Settings {&quot;user&quot;:&quot;jane&quot;}</div></div>"`
);
const server = renderToString(
<ServerContainer location={{ pathname: '/john/updates', search: '' }}>
{element}
</ServerContainer>
);
expect(server).toMatchInlineSnapshot(
`"<div><div>Profile undefined</div><div>Updates {&quot;user&quot;:&quot;john&quot;}</div></div>"`
);
});
it('gets the current options', () => {
const createTabNavigator = createNavigatorFactory((props: any) => {
const { navigation, state, descriptors } = useNavigationBuilder(
TabRouter,
props
);
return (
<NavigationHelpersContext.Provider value={navigation}>
{state.routes.map((route) => (
<div key={route.key}>{descriptors[route.key].render()}</div>
))}
</NavigationHelpersContext.Provider>
);
});
const Tab = createTabNavigator();
const TestScreen = ({ route }: any): any =>
`${route.name} ${JSON.stringify(route.params)}`;
const NestedStack = () => {
return (
<Tab.Navigator initialRouteName="Feed">
<Tab.Screen
name="Profile"
component={TestScreen}
options={{ title: 'My profile' }}
/>
<Tab.Screen
name="Settings"
component={TestScreen}
options={{ title: 'Configure' }}
/>
<Tab.Screen
name="Feed"
component={TestScreen}
options={{ title: 'News feed' }}
/>
<Tab.Screen
name="Updates"
component={TestScreen}
options={{ title: 'Updates from cloud', description: 'Woah' }}
/>
</Tab.Navigator>
);
};
const ref = React.createRef<ServerContainerRef>();
renderToString(
<ServerContainer ref={ref}>
<NavigationContainer
initialState={{
routes: [
{
name: 'Others',
state: {
routes: [{ name: 'Updates' }],
},
},
],
}}
>
<Tab.Navigator>
<Tab.Screen
name="Home"
component={TestScreen}
options={{ title: 'My app' }}
/>
<Tab.Screen
name="Others"
component={NestedStack}
options={{ title: 'Other stuff' }}
/>
</Tab.Navigator>
</NavigationContainer>
</ServerContainer>
);
expect(ref.current?.getCurrentOptions()).toEqual({
title: 'Updates from cloud',
description: 'Woah',
});
});

View File

@@ -15,3 +15,7 @@ export { default as useLinking } from './useLinking';
export { default as useLinkTo } from './useLinkTo'; export { default as useLinkTo } from './useLinkTo';
export { default as useLinkProps } from './useLinkProps'; export { default as useLinkProps } from './useLinkProps';
export { default as useLinkBuilder } from './useLinkBuilder'; export { default as useLinkBuilder } from './useLinkBuilder';
export { default as ServerContainer } from './ServerContainer';
export * from './types';

View File

@@ -51,3 +51,7 @@ export type LinkingOptions = {
*/ */
getPathFromState?: typeof getPathFromStateDefault; getPathFromState?: typeof getPathFromStateDefault;
}; };
export type ServerContainerRef = {
getCurrentOptions(): Record<string, any> | undefined;
};

View File

@@ -6,36 +6,226 @@ import {
NavigationState, NavigationState,
getActionFromState, getActionFromState,
} from '@react-navigation/core'; } from '@react-navigation/core';
import { nanoid } from 'nanoid/non-secure';
import ServerContext from './ServerContext';
import { LinkingOptions } from './types'; import { LinkingOptions } from './types';
type ResultState = ReturnType<typeof getStateFromPathDefault>; type ResultState = ReturnType<typeof getStateFromPathDefault>;
type HistoryState = { index: number }; type HistoryRecord = {
// Unique identifier for this record to match it with window.history.state
declare const history: { id: string;
state?: HistoryState; // Navigation state object for the history entry
go(delta: number): void; state: NavigationState;
pushState(state: HistoryState, title: string, url: string): void; // Path of the history entry
replaceState(state: HistoryState, title: string, url: string): void; path: string;
}; };
const getStateLength = (state: NavigationState) => { const createMemoryHistory = () => {
let length = 0; let index = 0;
let items: HistoryRecord[] = [];
if (state.history) { // Whether there's a `history.go(n)` pending
length = state.history.length; let pending = false;
} else {
length = state.index + 1; const history = {
get index(): number {
// We store an id in the state instead of an index
// Index could get out of sync with in-memory values if page reloads
const id = window.history.state?.id;
if (id) {
const index = items.findIndex((item) => item.id === id);
return index > -1 ? index : 0;
}
return 0;
},
get(index: number) {
return items[index]?.state;
},
backIndex({ path }: { path: string }) {
// We need to find the index from the element before current to get closest path to go back to
for (let i = index - 1; i >= 0; i--) {
const item = items[i];
if (item.path === path) {
return i;
}
}
return -1;
},
push({ path, state }: { path: string; state: NavigationState }) {
const id = nanoid();
// When a new entry is pushed, all the existing entries after index will be inaccessible
// So we remove any existing entries after the current index to clean them up
items = items.slice(0, index + 1);
items.push({ path, state, id });
index = items.length - 1;
// We pass empty string for title because it's ignored in all browsers except safari
// We don't store state object in history.state because:
// - browsers have limits on how big it can be, and we don't control the size
// - while not recommended, there could be non-serializable data in state
window.history.pushState({ id }, '', path);
},
replace({ path, state }: { path: string; state: NavigationState }) {
const id = window.history.state?.id ?? nanoid();
if (items.length) {
items[index] = { path, state, id };
} else {
// This is the first time any state modifications are done
// So we need to push the entry as there's nothing to replace
items.push({ path, state, id });
}
window.history.replaceState({ id }, '', path);
},
// `history.go(n)` is asynchronous, there are couple of things to keep in mind:
// - it won't do anything if we can't go `n` steps, the `popstate` event won't fire.
// - each `history.go(n)` call will trigger a separate `popstate` event with correct location.
// - the `popstate` event fires before the next frame after calling `history.go(n)`.
// This method differs from `history.go(n)` in the sense that it'll go back as many steps it can.
go(n: number) {
if (n > 0) {
// We shouldn't go forward more than available index
n = Math.min(n, items.length - 1);
} else if (n < 0) {
// We shouldn't go back more than the index
// Otherwise we'll exit the page
n = Math.max(n, -Math.max(index + 1, 1));
}
if (n === 0) {
return;
}
index += n;
return new Promise((resolve) => {
pending = true;
const done = () => {
pending = false;
window.removeEventListener('popstate', done);
resolve();
};
// Resolve the promise in the next frame
// If `popstate` hasn't fired by then, then it wasn't handled
requestAnimationFrame(() => requestAnimationFrame(done));
window.addEventListener('popstate', done);
window.history.go(n);
});
},
// The `popstate` event is triggered when history changes, except `pushState` and `replaceState`
// If we call `history.go(n)` ourselves, we don't want it to trigger the listener
// Here we normalize it so that only external changes (e.g. user pressing back/forward) trigger the listener
listen(listener: () => void) {
const onPopState = () => {
if (pending) {
// This was triggered by `history.go(n)`, we shouldn't call the listener
return;
}
listener();
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
},
};
return history;
};
/**
* Find the matching navigation state that changed between 2 navigation states
* e.g.: a -> b -> c -> d and a -> b -> c -> e -> f, if history in b changed, b is the matching state
*/
const findMatchingState = <T extends NavigationState>(
a: T | undefined,
b: T | undefined
): [T | undefined, T | undefined] => {
if (a === undefined || b === undefined || a.key !== b.key) {
return [undefined, undefined];
} }
const focusedState = state.routes[state.index].state; // Tab and drawer will have `history` property, but stack will have history in `routes`
const aHistoryLength = a.history ? a.history.length : a.routes.length;
const bHistoryLength = b.history ? b.history.length : b.routes.length;
if (focusedState && !focusedState.stale) { const aRoute = a.routes[a.index];
// If the focused route has history entries, we need to count them as well const bRoute = b.routes[b.index];
length += getStateLength(focusedState as NavigationState) - 1;
const aChildState = aRoute.state as T | undefined;
const bChildState = bRoute.state as T | undefined;
// Stop here if this is the state object that changed:
// - history length is different
// - focused routes are different
// - one of them doesn't have child state
// - child state keys are different
if (
aHistoryLength !== bHistoryLength ||
aRoute.key !== bRoute.key ||
aChildState === undefined ||
bChildState === undefined ||
aChildState.key !== bChildState.key
) {
return [a, b];
} }
return length; return findMatchingState(aChildState, bChildState);
};
/**
* Run async function in series as it's called.
*/
const series = (cb: () => Promise<void>) => {
// Whether we're currently handling a callback
let handling = false;
let queue: (() => Promise<void>)[] = [];
const callback = async () => {
try {
if (handling) {
// If we're currently handling a previous event, wait before handling this one
// Add the callback to the beginning of the queue
queue.unshift(callback);
return;
}
handling = true;
await cb();
} finally {
handling = false;
if (queue.length) {
// If we have queued items, handle the last one
const last = queue.pop();
last?.();
}
}
};
return callback;
}; };
let isUsingLinking = false; let isUsingLinking = false;
@@ -69,6 +259,8 @@ export default function useLinking(
}; };
}); });
const [history] = React.useState(createMemoryHistory);
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners // We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo` // This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect // Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
@@ -84,11 +276,17 @@ export default function useLinking(
getPathFromStateRef.current = getPathFromState; getPathFromStateRef.current = getPathFromState;
}, [config, enabled, getPathFromState, getStateFromPath]); }, [config, enabled, getPathFromState, getStateFromPath]);
const server = React.useContext(ServerContext);
const getInitialState = React.useCallback(() => { const getInitialState = React.useCallback(() => {
let value: ResultState | undefined; let value: ResultState | undefined;
if (enabledRef.current) { if (enabledRef.current) {
const path = location.pathname + location.search; const location =
server?.location ??
(typeof window !== 'undefined' ? window.location : undefined);
const path = location ? location.pathname + location.search : undefined;
if (path) { if (path) {
value = getStateFromPathRef.current(path, configRef.current); value = getStateFromPathRef.current(path, configRef.current);
@@ -106,205 +304,146 @@ export default function useLinking(
}; };
return thenable as PromiseLike<ResultState | undefined>; return thenable as PromiseLike<ResultState | undefined>;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const previousStateLengthRef = React.useRef<number | undefined>(undefined); const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
const previousHistoryIndexRef = React.useRef(0); const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
const pendingIndexChangeRef = React.useRef<number | undefined>();
const pendingStateUpdateRef = React.useRef<boolean>(false);
const pendingStateMultiUpdateRef = React.useRef<boolean>(false);
// If we're navigating ahead >1, we're not restoring whole state,
// but just navigate to the selected route not caring about previous routes
// therefore if we need to go back, we need to pop screen and navigate to the new one
// Possibly, we will need to reuse the same mechanism.
// E.g. if we went ahead+4 (numberOfIndicesAhead = 3), and back-2,
// actually we need to pop the screen we navigated
// and navigate again, setting numberOfIndicesAhead to 1.
const numberOfIndicesAhead = React.useRef(0);
React.useEffect(() => { React.useEffect(() => {
const onPopState = () => { return history.listen(() => {
const navigation = ref.current; const navigation = ref.current;
if (!navigation || !enabled) { if (!navigation || !enabled) {
return; return;
} }
const previousHistoryIndex = previousHistoryIndexRef.current; const path = location.pathname + location.search;
const historyIndex = history.state?.index ?? 0;
previousHistoryIndexRef.current = historyIndex; pendingPopStatePathRef.current = path;
if (pendingIndexChangeRef.current === historyIndex) { // When browser back/forward is clicked, we first need to check if state object for this index exists
pendingIndexChangeRef.current = undefined; // If it does we'll reset to that state object
// Otherwise, we'll handle it like a regular deep link
const recordedState = history.get(history.index);
if (recordedState) {
navigation.resetRoot(recordedState);
return; return;
} }
const state = navigation.getRootState(); const state = getStateFromPathRef.current(path, configRef.current);
const path = getPathFromStateRef.current(state, configRef.current);
let canGoBack = true; if (state) {
let numberOfBacks = 0; const action = getActionFromState(state);
if (previousHistoryIndex === historyIndex) { if (action !== undefined) {
if (location.pathname + location.search !== path) { navigation.dispatch(action);
pendingStateUpdateRef.current = true;
history.replaceState({ index: historyIndex }, '', path);
}
} else if (previousHistoryIndex > historyIndex) {
numberOfBacks =
previousHistoryIndex - historyIndex - numberOfIndicesAhead.current;
if (numberOfBacks > 0) {
pendingStateMultiUpdateRef.current = true;
if (numberOfBacks > 1) {
pendingStateMultiUpdateRef.current = true;
}
pendingStateUpdateRef.current = true;
for (let i = 0; i < numberOfBacks; i++) {
navigation.goBack();
}
} else { } else {
canGoBack = false; navigation.resetRoot(state);
} }
} else {
// if current path didn't return any state, we should revert to initial state
navigation.resetRoot(state);
} }
});
if (previousHistoryIndex < historyIndex || !canGoBack) { }, [enabled, history, ref]);
if (canGoBack) {
numberOfIndicesAhead.current =
historyIndex - previousHistoryIndex - 1;
} else {
navigation.goBack();
numberOfIndicesAhead.current -= previousHistoryIndex - historyIndex;
}
const state = getStateFromPathRef.current(
location.pathname + location.search,
configRef.current
);
pendingStateMultiUpdateRef.current = true;
if (state) {
const action = getActionFromState(state);
pendingStateUpdateRef.current = true;
if (action !== undefined) {
navigation.dispatch(action);
} else {
navigation.resetRoot(state);
}
}
}
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [enabled, ref]);
React.useEffect(() => { React.useEffect(() => {
if (!enabled) { if (!enabled) {
return; return;
} }
if (ref.current && previousStateLengthRef.current === undefined) { if (ref.current) {
previousStateLengthRef.current = getStateLength( // We need to record the current metadata on the first render if they aren't set
ref.current.getRootState() // This will allow the initial state to be in the history entry
); const state = ref.current.getRootState();
}
if (ref.current && location.pathname + location.search === '/') {
history.replaceState(
{ index: history.state?.index ?? 0 },
'',
getPathFromStateRef.current(
ref.current.getRootState(),
configRef.current
)
);
}
const unsubscribe = ref.current?.addListener('state', () => {
const navigation = ref.current;
if (!navigation) {
return;
}
const state = navigation.getRootState();
const path = getPathFromStateRef.current(state, configRef.current); const path = getPathFromStateRef.current(state, configRef.current);
const previousStateLength = previousStateLengthRef.current ?? 1; if (previousStateRef.current === undefined) {
const stateLength = getStateLength(state); previousStateRef.current = state;
if (pendingStateMultiUpdateRef.current) {
if (location.pathname + location.search === path) {
pendingStateMultiUpdateRef.current = false;
} else {
return;
}
} }
previousStateLengthRef.current = stateLength; history.replace({ path, state });
}
if ( const onStateChange = async () => {
pendingStateUpdateRef.current && const navigation = ref.current;
location.pathname + location.search === path
) { if (!navigation || !enabled) {
pendingStateUpdateRef.current = false;
return; return;
} }
let index = history.state?.index ?? 0; const previousState = previousStateRef.current;
const state = navigation.getRootState();
if (previousStateLength === stateLength) { const pendingPath = pendingPopStatePathRef.current;
// If no new entries were added to history in our navigation state, we want to replaceState const path = getPathFromStateRef.current(state, configRef.current);
if (location.pathname + location.search !== path) {
history.replaceState({ index }, '', path);
previousHistoryIndexRef.current = index;
}
} else if (stateLength > previousStateLength) {
// If new entries were added, pushState until we have same length
// This won't be accurate if multiple entries were added at once, but that's the best we can do
for (let i = 0, l = stateLength - previousStateLength; i < l; i++) {
index++;
history.pushState({ index }, '', path);
}
previousHistoryIndexRef.current = index; previousStateRef.current = state;
} else if (previousStateLength > stateLength) { pendingPopStatePathRef.current = undefined;
const delta = Math.min(
previousStateLength - stateLength,
// We need to keep at least one item in the history
// Otherwise we'll exit the page
previousHistoryIndexRef.current - 1
);
if (delta > 0) { // To detect the kind of state change, we need to:
// We need to set this to ignore the `popstate` event // - Find the common focused navigation state in previous and current state
pendingIndexChangeRef.current = index - delta; // - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace
// - If no common focused navigation state found, it's a replace
const [previousFocusedState, focusedState] = findMatchingState(
previousState,
state
);
// If new entries were removed, go back so that we have same length if (
history.go(-delta); previousFocusedState &&
} else { focusedState &&
// We're not going back in history, but the navigation state changed // We should only handle push/pop if path changed from what was in last `popstate`
// The URL probably also changed, so we need to re-sync the URL // Otherwise it's likely a change triggered by `popstate`
if (location.pathname + location.search !== path) { path !== pendingPath
history.replaceState({ index }, '', path); ) {
previousHistoryIndexRef.current = index; const historyDelta =
(focusedState.history
? focusedState.history.length
: focusedState.routes.length) -
(previousFocusedState.history
? previousFocusedState.history.length
: previousFocusedState.routes.length);
if (historyDelta > 0) {
// If history length is increased, we should pushState
// Note that path might not actually change here, for example, drawer open should pushState
history.push({ path, state });
} else if (historyDelta < 0) {
// If history length is decreased, i.e. entries were removed, we want to go back
const nextIndex = history.backIndex({ path });
const currentIndex = history.index;
if (nextIndex !== -1 && nextIndex < currentIndex) {
// An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
// This won't be correct if multiple routes were pushed in one go before
// Usually this shouldn't happen and this is a fallback for that
await history.go(historyDelta);
} }
}
}
});
return unsubscribe; // Store the updated state as well as fix the path if incorrect
history.replace({ path, state });
} else {
// If history length is unchanged, we want to replaceState
history.replace({ path, state });
}
} else {
// If no common navigation state was found, assume it's a replace
// This would happen if the user did a reset/conditionally changed navigators
history.replace({ path, state });
}
};
// We debounce onStateChange coz we don't want multiple state changes to be handled at one time
// This could happen since `history.go(n)` is asynchronous
// If `pushState` or `replaceState` were called before `history.go(n)` completes, it'll mess stuff up
return ref.current?.addListener('state', series(onStateChange));
}); });
return { return {

View File

@@ -3,6 +3,58 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.5.0...@react-navigation/stack@5.5.1) (2020-06-08)
### Bug Fixes
* make sure the header is on top of the view ([1ae07af](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/1ae07af79660973f4342a5741a1a826bcc689832))
# [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.2...@react-navigation/stack@5.5.0) (2020-06-08)
### Bug Fixes
* fix blank screen with animationEnabled: false & headerShown: false ([9c06a92](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/9c06a92d092af150d653c3a2f7fdccd28090bb14)), closes [#8391](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8391)
* ignore onOpen from route that wasn't closing ([1f27e4b](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/1f27e4b1f659e59ad15ecbf44b4fb0a80cae302f)), closes [#8257](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8257)
* pass gestureRef to PanGestureHandlerNative ([#8394](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8394)) ([c7e4bf9](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/c7e4bf94e664563892cbdafccc108ad519ccec50))
### Features
* automatically hide header in nested stacks ([e0e0f79](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/e0e0f79793be552e5532cd0afe9444000d21341e))
## [5.4.2](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.1...@react-navigation/stack@5.4.2) (2020-06-06)
### Bug Fixes
* relatively position float Header if !headerTransparent ([#8285](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8285)) ([78afbff](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/78afbffe976b14bb60666a2b1230127db0dc24f6))
## [5.4.1](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.0...@react-navigation/stack@5.4.1) (2020-05-27)
### Bug Fixes
* allow HeaderBackground's subViews to be touchable ([#8317](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8317)) ([00c23f2](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/00c23f2c9ed22fa4d010ffb427f2b52e061d8df4))
* fix type of style for various options ([9d822b9](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/9d822b95a6df797e2e63e481573e64ea7d0f9386))
# [5.4.0](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.3.9...@react-navigation/stack@5.4.0) (2020-05-23) # [5.4.0](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.3.9...@react-navigation/stack@5.4.0) (2020-05-23)

View File

@@ -1,7 +1,7 @@
{ {
"name": "@react-navigation/stack", "name": "@react-navigation/stack",
"description": "Stack navigator component for iOS and Android with animated transitions and gestures", "description": "Stack navigator component for iOS and Android with animated transitions and gestures",
"version": "5.4.0", "version": "5.5.1",
"keywords": [ "keywords": [
"react-native-component", "react-native-component",
"react-component", "react-component",
@@ -42,7 +42,7 @@
"devDependencies": { "devDependencies": {
"@react-native-community/bob": "^0.14.3", "@react-native-community/bob": "^0.14.3",
"@react-native-community/masked-view": "^0.1.10", "@react-native-community/masked-view": "^0.1.10",
"@react-navigation/native": "^5.4.3", "@react-navigation/native": "^5.5.1",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-native": "^0.62.7", "@types/react-native": "^0.62.7",

View File

@@ -119,7 +119,7 @@ export type StackHeaderOptions = {
* This may lead to white space or overlap between `headerLeft` and `headerTitle` if a customized `headerLeft` is used. * This may lead to white space or overlap between `headerLeft` and `headerTitle` if a customized `headerLeft` is used.
* It can be solved by adjusting `left` and `right` style in `headerTitleContainerStyle` and `marginHorizontal` in `headerTitleStyle`. * It can be solved by adjusting `left` and `right` style in `headerTitleContainerStyle` and `marginHorizontal` in `headerTitleStyle`.
*/ */
headerTitleContainerStyle?: StyleProp<ViewStyle>; headerTitleContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/** /**
* Tint color for the header. * Tint color for the header.
*/ */
@@ -133,7 +133,8 @@ export type StackHeaderOptions = {
*/ */
headerBackAllowFontScaling?: boolean; headerBackAllowFontScaling?: boolean;
/** /**
* Title string used by the back button on iOS, or `null` to disable label. Defaults to the previous scene's `headerTitle`. * Title string used by the back button on iOS. Defaults to the previous scene's `headerTitle`.
* Use `headerBackTitleVisible: false` to hide it.
*/ */
headerBackTitle?: string; headerBackTitle?: string;
/** /**
@@ -157,7 +158,7 @@ export type StackHeaderOptions = {
/** /**
* Style object for the container of the `headerLeft` component, for example to add padding. * Style object for the container of the `headerLeft` component, for example to add padding.
*/ */
headerLeftContainerStyle?: StyleProp<ViewStyle>; headerLeftContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/** /**
* Function which returns a React Element to display on the right side of the header. * Function which returns a React Element to display on the right side of the header.
*/ */
@@ -165,7 +166,7 @@ export type StackHeaderOptions = {
/** /**
* Style object for the container of the `headerRight` component, for example to add padding. * Style object for the container of the `headerRight` component, for example to add padding.
*/ */
headerRightContainerStyle?: StyleProp<ViewStyle>; headerRightContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/** /**
* Function which returns a React Element to display custom image in header's back button. * Function which returns a React Element to display custom image in header's back button.
* It receives the `tintColor` in in the options object as an argument. object. * It receives the `tintColor` in in the options object as an argument. object.
@@ -187,7 +188,7 @@ export type StackHeaderOptions = {
/** /**
* Style object for the header. You can specify a custom background color here, for example. * Style object for the header. You can specify a custom background color here, for example.
*/ */
headerStyle?: StyleProp<ViewStyle>; headerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/** /**
* Defaults to `false`. If `true`, the header will not have a background unless you explicitly provide it with `headerBackground`. * Defaults to `false`. If `true`, the header will not have a background unless you explicitly provide it with `headerBackground`.
* The header will also float over the screen so that it overlaps the content underneath. * The header will also float over the screen so that it overlaps the content underneath.
@@ -380,7 +381,7 @@ export type StackHeaderLeftButtonProps = {
/** /**
* Style object for the label. * Style object for the label.
*/ */
labelStyle?: React.ComponentProps<typeof Animated.Text>['style']; labelStyle?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
/** /**
* Whether label font should scale to respect Text Size accessibility settings. * Whether label font should scale to respect Text Size accessibility settings.
*/ */

View File

@@ -0,0 +1,5 @@
import * as React from 'react';
const HeaderShownContext = React.createContext(false);
export default HeaderShownContext;

View File

@@ -10,7 +10,7 @@ export function PanGestureHandler(props: PanGestureHandlerProperties) {
return ( return (
<GestureHandlerRefContext.Provider value={gestureRef}> <GestureHandlerRefContext.Provider value={gestureRef}>
<PanGestureHandlerNative {...props} /> <PanGestureHandlerNative {...props} ref={gestureRef} />
</GestureHandlerRefContext.Provider> </GestureHandlerRefContext.Provider>
); );
} }

View File

@@ -1,8 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { Animated, StyleSheet, Platform, ViewProps } from 'react-native'; import {
Animated,
StyleSheet,
Platform,
ViewProps,
StyleProp,
ViewStyle,
} from 'react-native';
import { useTheme } from '@react-navigation/native'; import { useTheme } from '@react-navigation/native';
type Props = ViewProps & { type Props = ViewProps & {
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
children?: React.ReactNode; children?: React.ReactNode;
}; };

View File

@@ -15,6 +15,7 @@ import {
forNoAnimation, forNoAnimation,
forSlideRight, forSlideRight,
} from '../../TransitionConfigs/HeaderStyleInterpolators'; } from '../../TransitionConfigs/HeaderStyleInterpolators';
import HeaderShownContext from '../../utils/HeaderShownContext';
import { import {
Layout, Layout,
Scene, Scene,
@@ -54,6 +55,7 @@ export default function HeaderContainer({
style, style,
}: Props) { }: Props) {
const focusedRoute = getFocusedRoute(); const focusedRoute = getFocusedRoute();
const isParentHeaderShown = React.useContext(HeaderShownContext);
return ( return (
<View pointerEvents="box-none" style={style}> <View pointerEvents="box-none" style={style}>
@@ -62,7 +64,16 @@ export default function HeaderContainer({
return null; return null;
} }
const { options } = scene.descriptor; const {
header,
headerShown = isParentHeaderShown === false,
headerTransparent,
} = scene.descriptor.options || {};
if (!headerShown) {
return null;
}
const isFocused = focusedRoute.key === scene.route.key; const isFocused = focusedRoute.key === scene.route.key;
const previousRoute = getPreviousRoute({ route: scene.route }); const previousRoute = getPreviousRoute({ route: scene.route });
@@ -85,13 +96,20 @@ export default function HeaderContainer({
// This makes the header look like it's moving with the screen // This makes the header look like it's moving with the screen
const previousScene = self[i - 1]; const previousScene = self[i - 1];
const nextScene = self[i + 1]; const nextScene = self[i + 1];
const {
headerShown: previousHeaderShown = isParentHeaderShown === false,
} = previousScene?.descriptor.options || {};
const { headerShown: nextHeaderShown = isParentHeaderShown === false } =
nextScene?.descriptor.options || {};
const isHeaderStatic = const isHeaderStatic =
(previousScene && (previousHeaderShown === false &&
previousScene.descriptor.options.headerShown === false &&
// We still need to animate when coming back from next scene // We still need to animate when coming back from next scene
// A hacky way to check this is if the next scene exists // A hacky way to check this is if the next scene exists
!nextScene) || !nextScene) ||
(nextScene && nextScene.descriptor.options.headerShown === false); nextHeaderShown === false;
const props = { const props = {
mode, mode,
@@ -139,18 +157,12 @@ export default function HeaderContainer({
style={ style={
// Avoid positioning the focused header absolutely // Avoid positioning the focused header absolutely
// Otherwise accessibility tools don't seem to be able to find it // Otherwise accessibility tools don't seem to be able to find it
(mode === 'float' && !isFocused) || options.headerTransparent (mode === 'float' && !isFocused) || headerTransparent
? styles.header ? styles.header
: null : null
} }
> >
{options.headerShown !== false ? ( {header !== undefined ? header(props) : <Header {...props} />}
options.header !== undefined ? (
options.header(props)
) : (
<Header {...props} />
)
) : null}
</View> </View>
</NavigationRouteContext.Provider> </NavigationRouteContext.Provider>
</NavigationContext.Provider> </NavigationContext.Provider>

View File

@@ -312,8 +312,8 @@ export default class HeaderSegment extends React.Component<Props, State> {
return ( return (
<React.Fragment> <React.Fragment>
<Animated.View <Animated.View
pointerEvents="none" pointerEvents="box-none"
style={[StyleSheet.absoluteFill, backgroundStyle]} style={[StyleSheet.absoluteFill, { zIndex: 0 }, backgroundStyle]}
> >
{headerBackground ? ( {headerBackground ? (
headerBackground({ style: safeStyles }) headerBackground({ style: safeStyles })

View File

@@ -46,7 +46,9 @@ type Props = ViewProps & {
onGestureCanceled?: () => void; onGestureCanceled?: () => void;
onGestureEnd?: () => void; onGestureEnd?: () => void;
children: React.ReactNode; children: React.ReactNode;
overlay: (props: { style: StyleProp<ViewStyle> }) => React.ReactNode; overlay: (props: {
style: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
}) => React.ReactNode;
overlayEnabled: boolean; overlayEnabled: boolean;
shadowEnabled: boolean; shadowEnabled: boolean;
gestureEnabled: boolean; gestureEnabled: boolean;
@@ -83,7 +85,11 @@ export default class Card extends React.Component<Props> {
shadowEnabled: true, shadowEnabled: true,
gestureEnabled: true, gestureEnabled: true,
gestureVelocityImpact: GESTURE_VELOCITY_IMPACT, gestureVelocityImpact: GESTURE_VELOCITY_IMPACT,
overlay: ({ style }: { style: StyleProp<ViewStyle> }) => overlay: ({
style,
}: {
style: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
}) =>
style ? ( style ? (
<Animated.View pointerEvents="none" style={[styles.overlay, style]} /> <Animated.View pointerEvents="none" style={[styles.overlay, style]} />
) : null, ) : null,
@@ -487,6 +493,12 @@ export default class Card extends React.Component<Props> {
? Color(backgroundColor).alpha() === 0 ? Color(backgroundColor).alpha() === 0
: false; : false;
// This is a dummy style that doesn't actually change anything visually.
// Animated needs the animated value to be used somewhere, otherwise things don't update properly.
// If we disable animations and hide header, it could end up making the value unused.
// So we have this dummy style that will always be used regardless of what else changed.
const dummyStyle = { opacity: Animated.diffClamp(current, 1, 1) };
return ( return (
<CardAnimationContext.Provider value={animationContext}> <CardAnimationContext.Provider value={animationContext}>
<View pointerEvents="box-none" {...rest}> <View pointerEvents="box-none" {...rest}>
@@ -496,7 +508,12 @@ export default class Card extends React.Component<Props> {
</View> </View>
) : null} ) : null}
<Animated.View <Animated.View
style={[styles.container, containerStyle, customContainerStyle]} style={[
styles.container,
dummyStyle,
containerStyle,
customContainerStyle,
]}
pointerEvents="box-none" pointerEvents="box-none"
> >
<PanGestureHandler <PanGestureHandler

View File

@@ -4,6 +4,7 @@ import { Route, useTheme } from '@react-navigation/native';
import { Props as HeaderContainerProps } from '../Header/HeaderContainer'; import { Props as HeaderContainerProps } from '../Header/HeaderContainer';
import Card from './Card'; import Card from './Card';
import HeaderHeightContext from '../../utils/HeaderHeightContext'; import HeaderHeightContext from '../../utils/HeaderHeightContext';
import HeaderShownContext from '../../utils/HeaderShownContext';
import { import {
Scene, Scene,
Layout, Layout,
@@ -53,8 +54,8 @@ type Props = TransitionPreset & {
gestureVelocityImpact?: number; gestureVelocityImpact?: number;
mode: StackCardMode; mode: StackCardMode;
headerMode: StackHeaderMode; headerMode: StackHeaderMode;
headerShown?: boolean; headerShown: boolean;
headerTransparent?: boolean; hasAbsoluteHeader: boolean;
headerHeight: number; headerHeight: number;
onHeaderHeightChange: (props: { onHeaderHeightChange: (props: {
route: Route<string>; route: Route<string>;
@@ -84,7 +85,7 @@ function CardContainer({
headerMode, headerMode,
headerShown, headerShown,
headerStyleInterpolator, headerStyleInterpolator,
headerTransparent, hasAbsoluteHeader,
headerHeight, headerHeight,
onHeaderHeightChange, onHeaderHeightChange,
index, index,
@@ -160,6 +161,9 @@ function CardContainer({
}; };
}, [pointerEvents, scene.progress.next]); }, [pointerEvents, scene.progress.next]);
const isParentHeaderShown = React.useContext(HeaderShownContext);
const isCurrentHeaderShown = headerMode !== 'none' && headerShown !== false;
return ( return (
<Card <Card
index={index} index={index}
@@ -187,19 +191,19 @@ function CardContainer({
importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'} importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
pointerEvents={active ? 'box-none' : pointerEvents} pointerEvents={active ? 'box-none' : pointerEvents}
pageOverflowEnabled={headerMode === 'screen' && mode === 'card'} pageOverflowEnabled={headerMode === 'screen' && mode === 'card'}
containerStyle={ containerStyle={hasAbsoluteHeader ? { marginTop: headerHeight } : null}
headerMode === 'float' && !headerTransparent && headerShown !== false
? { marginTop: headerHeight }
: null
}
contentStyle={[{ backgroundColor: colors.background }, cardStyle]} contentStyle={[{ backgroundColor: colors.background }, cardStyle]}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
> >
<View style={styles.container}> <View style={styles.container}>
<View style={styles.scene}> <View style={styles.scene}>
<HeaderHeightContext.Provider value={headerHeight}> <HeaderShownContext.Provider
{renderScene({ route: scene.route })} value={isParentHeaderShown || isCurrentHeaderShown}
</HeaderHeightContext.Provider> >
<HeaderHeightContext.Provider value={headerHeight}>
{renderScene({ route: scene.route })}
</HeaderHeightContext.Provider>
</HeaderShownContext.Provider>
</View> </View>
{headerMode === 'screen' {headerMode === 'screen'
? renderHeader({ ? renderHeader({

View File

@@ -19,6 +19,7 @@ import {
} from '../../TransitionConfigs/TransitionPresets'; } from '../../TransitionConfigs/TransitionPresets';
import { forNoAnimation as forNoAnimationHeader } from '../../TransitionConfigs/HeaderStyleInterpolators'; import { forNoAnimation as forNoAnimationHeader } from '../../TransitionConfigs/HeaderStyleInterpolators';
import { forNoAnimation as forNoAnimationCard } from '../../TransitionConfigs/CardStyleInterpolators'; import { forNoAnimation as forNoAnimationCard } from '../../TransitionConfigs/CardStyleInterpolators';
import HeaderShownContext from '../../utils/HeaderShownContext';
import getDistanceForDirection from '../../utils/getDistanceForDirection'; import getDistanceForDirection from '../../utils/getDistanceForDirection';
import { import {
Layout, Layout,
@@ -83,9 +84,10 @@ const getHeaderHeights = (
) => { ) => {
return routes.reduce<Record<string, number>>((acc, curr) => { return routes.reduce<Record<string, number>>((acc, curr) => {
const { options = {} } = descriptors[curr.key] || {}; const { options = {} } = descriptors[curr.key] || {};
const { height = previous[curr.key] } = StyleSheet.flatten( const style: any = StyleSheet.flatten(options.headerStyle || {});
options.headerStyle || {}
); const height =
typeof style.height === 'number' ? style.height : previous[curr.key];
const safeAreaInsets = { const safeAreaInsets = {
...insets, ...insets,
@@ -384,180 +386,224 @@ export default class CardStack extends React.Component<Props, State> {
const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal'; const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal';
return ( return (
<React.Fragment> <HeaderShownContext.Consumer>
<MaybeScreenContainer {(isParentHeaderShown) => {
enabled={isScreensEnabled} const isFloatHeaderAbsolute =
style={styles.container} headerMode === 'float'
onLayout={this.handleLayout} ? this.state.scenes.slice(-2).some((scene) => {
> const { descriptor } = scene;
{routes.map((route, index, self) => { const options = descriptor ? descriptor.options : {};
const focused = focusedRoute.key === route.key; const {
const gesture = gestures[route.key]; headerTransparent,
const scene = scenes[index]; headerShown = isParentHeaderShown === false,
} = options;
const isScreenActive = scene.progress.next if (headerTransparent || headerShown === false) {
? scene.progress.next.interpolate({ return true;
inputRange: [0, 1 - EPSILON, 1], }
outputRange: [1, 1, 0],
extrapolate: 'clamp', return false;
}) })
: 1; : false;
const { const floatingHeader =
safeAreaInsets, headerMode === 'float' ? (
headerShown, <React.Fragment key="header">
headerTransparent, {renderHeader({
cardShadowEnabled, mode: 'float',
cardOverlayEnabled, layout,
cardOverlay, insets: { top, right, bottom, left },
cardStyle, scenes,
animationEnabled, getPreviousRoute,
gestureResponseDistance, getFocusedRoute: this.getFocusedRoute,
gestureVelocityImpact, onContentHeightChange: this.handleHeaderLayout,
gestureDirection = defaultTransitionPreset.gestureDirection, gestureDirection:
transitionSpec = defaultTransitionPreset.transitionSpec, focusedOptions.gestureDirection !== undefined
cardStyleInterpolator = animationEnabled === false ? focusedOptions.gestureDirection
? forNoAnimationCard : defaultTransitionPreset.gestureDirection,
: defaultTransitionPreset.cardStyleInterpolator, styleInterpolator:
headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator, focusedOptions.headerStyleInterpolator !== undefined
} = scene.descriptor ? focusedOptions.headerStyleInterpolator
? scene.descriptor.options : defaultTransitionPreset.headerStyleInterpolator,
: ({} as StackNavigationOptions); style: [
styles.floating,
isFloatHeaderAbsolute && styles.absolute,
],
})}
</React.Fragment>
) : null;
let transitionConfig = { return (
gestureDirection, <React.Fragment>
transitionSpec, {isFloatHeaderAbsolute ? null : floatingHeader}
cardStyleInterpolator, <MaybeScreenContainer
headerStyleInterpolator,
};
// When a screen is not the last, it should use next screen's transition config
// Many transitions also animate the previous screen, so using 2 different transitions doesn't look right
// For example combining a slide and a modal transition would look wrong otherwise
// With this approach, combining different transition styles in the same navigator mostly looks right
// This will still be broken when 2 transitions have different idle state (e.g. modal presentation),
// but majority of the transitions look alright
if (index !== self.length - 1) {
const nextScene = scenes[index + 1];
if (nextScene) {
const {
animationEnabled,
gestureDirection = defaultTransitionPreset.gestureDirection,
transitionSpec = defaultTransitionPreset.transitionSpec,
cardStyleInterpolator = animationEnabled === false
? forNoAnimationCard
: defaultTransitionPreset.cardStyleInterpolator,
headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
} = nextScene.descriptor
? nextScene.descriptor.options
: ({} as StackNavigationOptions);
transitionConfig = {
gestureDirection,
transitionSpec,
cardStyleInterpolator,
headerStyleInterpolator,
};
}
}
const {
top: safeAreaInsetTop = insets.top,
right: safeAreaInsetRight = insets.right,
bottom: safeAreaInsetBottom = insets.bottom,
left: safeAreaInsetLeft = insets.left,
} = safeAreaInsets || {};
const previousRoute = getPreviousRoute({ route: scene.route });
let previousScene = scenes[index - 1];
if (previousRoute) {
// The previous scene will be shortly before the current scene in the array
// So loop back from current index to avoid looping over the full array
for (let j = index - 1; j >= 0; j--) {
const s = scenes[j];
if (s && s.route.key === previousRoute.key) {
previousScene = s;
break;
}
}
}
return (
<MaybeScreen
key={route.key}
style={StyleSheet.absoluteFill}
enabled={isScreensEnabled} enabled={isScreensEnabled}
active={isScreenActive} style={styles.container}
pointerEvents="box-none" onLayout={this.handleLayout}
> >
<CardContainer {routes.map((route, index, self) => {
index={index} const focused = focusedRoute.key === route.key;
active={index === self.length - 1} const gesture = gestures[route.key];
focused={focused} const scene = scenes[index];
closing={closingRouteKeys.includes(route.key)}
layout={layout} const isScreenActive = scene.progress.next
gesture={gesture} ? scene.progress.next.interpolate({
scene={scene} inputRange: [0, 1 - EPSILON, 1],
previousScene={previousScene} outputRange: [1, 1, 0],
safeAreaInsetTop={safeAreaInsetTop} extrapolate: 'clamp',
safeAreaInsetRight={safeAreaInsetRight} })
safeAreaInsetBottom={safeAreaInsetBottom} : 1;
safeAreaInsetLeft={safeAreaInsetLeft}
cardOverlay={cardOverlay} const {
cardOverlayEnabled={cardOverlayEnabled} safeAreaInsets,
cardShadowEnabled={cardShadowEnabled} headerShown = isParentHeaderShown === false,
cardStyle={cardStyle} headerTransparent,
onPageChangeStart={onPageChangeStart} cardShadowEnabled,
onPageChangeConfirm={onPageChangeConfirm} cardOverlayEnabled,
onPageChangeCancel={onPageChangeCancel} cardOverlay,
gestureResponseDistance={gestureResponseDistance} cardStyle,
headerHeight={headerHeights[route.key]} animationEnabled,
onHeaderHeightChange={this.handleHeaderLayout} gestureResponseDistance,
getPreviousRoute={getPreviousRoute} gestureVelocityImpact,
getFocusedRoute={this.getFocusedRoute} gestureDirection = defaultTransitionPreset.gestureDirection,
mode={mode} transitionSpec = defaultTransitionPreset.transitionSpec,
headerMode={headerMode} cardStyleInterpolator = animationEnabled === false
headerShown={headerShown} ? forNoAnimationCard
headerTransparent={headerTransparent} : defaultTransitionPreset.cardStyleInterpolator,
renderHeader={renderHeader} headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
renderScene={renderScene} } = scene.descriptor
onOpenRoute={onOpenRoute} ? scene.descriptor.options
onCloseRoute={onCloseRoute} : ({} as StackNavigationOptions);
onTransitionStart={onTransitionStart}
onTransitionEnd={onTransitionEnd} let transitionConfig = {
gestureEnabled={index !== 0 && getGesturesEnabled({ route })} gestureDirection,
gestureVelocityImpact={gestureVelocityImpact} transitionSpec,
{...transitionConfig} cardStyleInterpolator,
/> headerStyleInterpolator,
</MaybeScreen> };
);
})} // When a screen is not the last, it should use next screen's transition config
</MaybeScreenContainer> // Many transitions also animate the previous screen, so using 2 different transitions doesn't look right
{headerMode === 'float' // For example combining a slide and a modal transition would look wrong otherwise
? renderHeader({ // With this approach, combining different transition styles in the same navigator mostly looks right
mode: 'float', // This will still be broken when 2 transitions have different idle state (e.g. modal presentation),
layout, // but majority of the transitions look alright
insets: { top, right, bottom, left }, if (index !== self.length - 1) {
scenes, const nextScene = scenes[index + 1];
getPreviousRoute,
getFocusedRoute: this.getFocusedRoute, if (nextScene) {
onContentHeightChange: this.handleHeaderLayout, const {
gestureDirection: animationEnabled,
focusedOptions.gestureDirection !== undefined gestureDirection = defaultTransitionPreset.gestureDirection,
? focusedOptions.gestureDirection transitionSpec = defaultTransitionPreset.transitionSpec,
: defaultTransitionPreset.gestureDirection, cardStyleInterpolator = animationEnabled === false
styleInterpolator: ? forNoAnimationCard
focusedOptions.headerStyleInterpolator !== undefined : defaultTransitionPreset.cardStyleInterpolator,
? focusedOptions.headerStyleInterpolator headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
: defaultTransitionPreset.headerStyleInterpolator, } = nextScene.descriptor
style: styles.floating, ? nextScene.descriptor.options
}) : ({} as StackNavigationOptions);
: null}
</React.Fragment> transitionConfig = {
gestureDirection,
transitionSpec,
cardStyleInterpolator,
headerStyleInterpolator,
};
}
}
const {
top: safeAreaInsetTop = insets.top,
right: safeAreaInsetRight = insets.right,
bottom: safeAreaInsetBottom = insets.bottom,
left: safeAreaInsetLeft = insets.left,
} = safeAreaInsets || {};
const previousRoute = getPreviousRoute({
route: scene.route,
});
let previousScene = scenes[index - 1];
if (previousRoute) {
// The previous scene will be shortly before the current scene in the array
// So loop back from current index to avoid looping over the full array
for (let j = index - 1; j >= 0; j--) {
const s = scenes[j];
if (s && s.route.key === previousRoute.key) {
previousScene = s;
break;
}
}
}
const headerHeight =
headerMode !== 'none' && headerShown !== false
? headerHeights[route.key]
: 0;
return (
<MaybeScreen
key={route.key}
style={StyleSheet.absoluteFill}
enabled={isScreensEnabled}
active={isScreenActive}
pointerEvents="box-none"
>
<CardContainer
index={index}
active={index === self.length - 1}
focused={focused}
closing={closingRouteKeys.includes(route.key)}
layout={layout}
gesture={gesture}
scene={scene}
previousScene={previousScene}
safeAreaInsetTop={safeAreaInsetTop}
safeAreaInsetRight={safeAreaInsetRight}
safeAreaInsetBottom={safeAreaInsetBottom}
safeAreaInsetLeft={safeAreaInsetLeft}
cardOverlay={cardOverlay}
cardOverlayEnabled={cardOverlayEnabled}
cardShadowEnabled={cardShadowEnabled}
cardStyle={cardStyle}
onPageChangeStart={onPageChangeStart}
onPageChangeConfirm={onPageChangeConfirm}
onPageChangeCancel={onPageChangeCancel}
gestureResponseDistance={gestureResponseDistance}
headerHeight={headerHeight}
onHeaderHeightChange={this.handleHeaderLayout}
getPreviousRoute={getPreviousRoute}
getFocusedRoute={this.getFocusedRoute}
mode={mode}
headerMode={headerMode}
headerShown={headerShown}
hasAbsoluteHeader={
isFloatHeaderAbsolute && !headerTransparent
}
renderHeader={renderHeader}
renderScene={renderScene}
onOpenRoute={onOpenRoute}
onCloseRoute={onCloseRoute}
onTransitionStart={onTransitionStart}
onTransitionEnd={onTransitionEnd}
gestureEnabled={
index !== 0 && getGesturesEnabled({ route })
}
gestureVelocityImpact={gestureVelocityImpact}
{...transitionConfig}
/>
</MaybeScreen>
);
})}
</MaybeScreenContainer>
{isFloatHeaderAbsolute ? floatingHeader : null}
</React.Fragment>
);
}}
</HeaderShownContext.Consumer>
); );
} }
} }
@@ -566,10 +612,13 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
floating: { absolute: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
}, },
floating: {
zIndex: 1,
},
}); });

View File

@@ -330,13 +330,15 @@ export default class StackView extends React.Component<Props, State> {
private handleOpenRoute = ({ route }: { route: Route<string> }) => { private handleOpenRoute = ({ route }: { route: Route<string> }) => {
const { state, navigation } = this.props; const { state, navigation } = this.props;
const { closingRouteKeys, replacingRouteKeys } = this.state;
if ( if (
this.state.replacingRouteKeys.every((key) => key !== route.key) && closingRouteKeys.some((key) => key === route.key) &&
replacingRouteKeys.every((key) => key !== route.key) &&
state.routeNames.includes(route.name) && state.routeNames.includes(route.name) &&
!state.routes.some((r) => r.key === route.key) !state.routes.some((r) => r.key === route.key)
) { ) {
// If route isn't present in current state, assume that a close animation was cancelled // If route isn't present in current state, but was closing, assume that a close animation was cancelled
// So we need to add this route back to the state // So we need to add this route back to the state
navigation.navigate(route); navigation.navigate(route);
} else { } else {
@@ -409,6 +411,9 @@ export default class StackView extends React.Component<Props, State> {
navigation, navigation,
keyboardHandlingEnabled, keyboardHandlingEnabled,
mode = 'card', mode = 'card',
headerMode = mode === 'card' && Platform.OS === 'ios'
? 'float'
: 'screen',
...rest ...rest
} = this.props; } = this.props;
@@ -419,9 +424,6 @@ export default class StackView extends React.Component<Props, State> {
closingRouteKeys, closingRouteKeys,
} = this.state; } = this.state;
const headerMode =
mode === 'card' && Platform.OS === 'ios' ? 'float' : 'screen';
return ( return (
<NavigationHelpersContext.Provider value={navigation}> <NavigationHelpersContext.Provider value={navigation}>
<GestureHandlerWrapper style={styles.container}> <GestureHandlerWrapper style={styles.container}>

635
yarn.lock

File diff suppressed because it is too large Load Diff