Compare commits

..

20 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
49 changed files with 1676 additions and 545 deletions

View File

@@ -27,10 +27,8 @@ jobs:
id: yarn-cache
uses: actions/cache@master
with:
path: |
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'

View File

@@ -29,10 +29,8 @@ jobs:
id: yarn-cache
uses: actions/cache@master
with:
path: |
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'

View File

@@ -40,8 +40,8 @@ app.use(async (ctx) => {
>
${css}
<title>${ref.current?.getCurrentOptions()?.title}</title>
<body style="height: 100%">
<div id="root" style="display: flex; height: 100%">
<body style="min-height: 100%">
<div id="root" style="display: flex; min-height: 100vh">
${html}
</div>
`;

View File

@@ -71,13 +71,12 @@ export default function BottomTabsScreen() {
>
<BottomTabs.Screen
name="Article"
component={SimpleStackScreen}
options={{
title: 'Article',
tabBarIcon: getTabBarIcon('file-document-box'),
}}
>
{(props) => <SimpleStackScreen {...props} headerMode="none" />}
</BottomTabs.Screen>
/>
<BottomTabs.Screen
name="Chat"
component={Chat}

View File

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

View File

@@ -91,7 +91,6 @@ export default function SimpleStackScreen({ navigation, options }: Props) {
return (
<ModalPresentationStack.Navigator
mode="modal"
headerMode="screen"
screenOptions={({ route, navigation }) => ({
...TransitionPresets.ModalPresentationIOS,
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>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
type Props = {
navigation: StackNavigationProp<ParamListBase>;
};
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
export default function SimpleStackScreen({ navigation }: Props) {
navigation.setOptions({
headerShown: false,
});
return (
<SimpleStack.Navigator {...rest}>
<SimpleStack.Navigator>
<SimpleStack.Screen
name="Article"
component={ArticleScreen}

View File

@@ -1,13 +1,22 @@
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 MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { RouteProp, ParamListBase } from '@react-navigation/native';
import { useTheme, RouteProp, ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
HeaderBackground,
useHeaderHeight,
Header,
StackHeaderProps,
} from '@react-navigation/stack';
import BlurView from '../Shared/BlurView';
import Article from '../Shared/Article';
@@ -91,11 +100,32 @@ type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
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) {
navigation.setOptions({
headerShown: false,
});
const { colors, dark } = useTheme();
return (
<SimpleStack.Navigator {...rest}>
<SimpleStack.Screen
@@ -103,6 +133,7 @@ export default function SimpleStackScreen({ navigation, ...rest }: Props) {
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params?.author}`,
header: CustomHeader,
headerTintColor: '#fff',
headerStyle: { backgroundColor: '#ff005d' },
headerBackTitleVisible: false,
@@ -138,9 +169,15 @@ export default function SimpleStackScreen({ navigation, ...rest }: Props) {
headerBackTitle: 'Back',
headerTransparent: true,
headerBackground: () => (
<HeaderBackground style={{ backgroundColor: 'transparent' }}>
<HeaderBackground
style={{
backgroundColor: 'transparent',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
}}
>
<BlurView
tint="light"
tint={dark ? 'dark' : 'light'}
intensity={75}
style={StyleSheet.absoluteFill}
/>
@@ -160,4 +197,10 @@ const styles = StyleSheet.create({
button: {
margin: 8,
},
banner: {
textAlign: 'center',
color: 'tomato',
backgroundColor: 'papayawhip',
padding: 4,
},
});

View File

@@ -26,6 +26,7 @@ import {
NavigationContainer,
DefaultTheme,
DarkTheme,
PathConfig,
} from '@react-navigation/native';
import {
createDrawerNavigator,
@@ -48,6 +49,7 @@ import StackHeaderCustomization from './Screens/StackHeaderCustomization';
import BottomTabs from './Screens/BottomTabs';
import MaterialTopTabsScreen from './Screens/MaterialTopTabs';
import MaterialBottomTabs from './Screens/MaterialBottomTabs';
import NotFound from './Screens/NotFound';
import DynamicTabs from './Screens/DynamicTabs';
import AuthFlow from './Screens/AuthFlow';
import CompatAPI from './Screens/CompatAPI';
@@ -68,6 +70,7 @@ type RootDrawerParamList = {
type RootStackParamList = {
Home: undefined;
NotFound: undefined;
} & {
[P in keyof typeof SCREENS]: undefined;
};
@@ -221,35 +224,45 @@ export default function App() {
Root: {
path: '',
initialRouteName: 'Home',
screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>(
screens: Object.keys(SCREENS).reduce<PathConfig>(
(acc, name) => {
// Convert screen names such as SimpleStack to kebab case (simple-stack)
acc[name] = name
const path = name
.replace(/([A-Z]+)/g, '-$1')
.replace(/^-/, '')
.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;
},
{ 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>}
@@ -332,6 +345,11 @@ export default function App() {
</ScrollView>
)}
</Stack.Screen>
<Stack.Screen
name="NotFound"
component={NotFound}
options={{ title: 'Oops!' }}
/>
{(Object.keys(SCREENS) as (keyof typeof SCREENS)[]).map(
(name) => (
<Stack.Screen

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
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)

View File

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

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
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

View File

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

View File

@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
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)

View File

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

View File

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

View File

@@ -1265,3 +1265,175 @@ it('replaces undefined query params', () => {
expect(getPathFromState(state, 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
);
});
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>;
};
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.
*
@@ -69,7 +82,8 @@ export default function getPathFromState(
let pattern: string | undefined;
let currentParams: Record<string, any> = { ...route.params };
let focusedParams: Record<string, any> | undefined;
let focusedRoute = getActiveRoute(state);
let currentOptions = configs;
// 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) {
const stringify = currentOptions[route.name]?.stringify;
currentParams = fromEntries(
const currentParams = fromEntries(
Object.entries(route.params).map(([key, value]) => [
key,
stringify?.[key] ? stringify[key](value) : String(value),
@@ -95,6 +109,26 @@ export default function getPathFromState(
if (pattern) {
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
@@ -128,18 +162,19 @@ export default function getPathFromState(
path += pattern
.split('/')
.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 (p.startsWith(':')) {
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('?')) {
// Optional params without value assigned in route.params should be ignored
return '';
@@ -155,17 +190,21 @@ export default function getPathFromState(
path += encodeURIComponent(route.name);
}
if (!focusedParams) {
focusedParams = focusedRoute.params;
}
if (route.state) {
path += '/';
} else if (currentParams) {
for (let param in currentParams) {
if (currentParams[param] === 'undefined') {
} else if (focusedParams) {
for (let param in focusedParams) {
if (focusedParams[param] === 'undefined') {
// 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) {
path += `?${query}`;
@@ -189,6 +228,9 @@ const fromEntries = <K extends string, V>(entries: (readonly [K, V])[]) =>
return acc;
}, {} as Record<K, V>);
const getParamName = (pattern: string) =>
pattern.replace(/^:/, '').replace(/\?$/, '');
const joinPaths = (...paths: string[]): string =>
([] as string[])
.concat(...paths.map((p) => p.split('/')))

View File

@@ -59,11 +59,46 @@ export default function getStateFromPath(
createNormalizedConfigs(key, options, [], initialRoutes)
)
)
.sort(
(a, b) =>
// Sort configs so the most exhaustive is always first to be chosen
b.pattern.split('/').length - a.pattern.split('/').length
);
.sort((a, b) => {
// Sort config so that:
// - the most exhaustive ones are always at the beginning
// - 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
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
@@ -104,41 +139,37 @@ export default function getStateFromPath(
let result: 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) {
let routeNames: string[] | undefined;
let allParams: Record<string, any> | undefined;
let { routeNames, allParams, remainingPath } = matchAgainstConfigs(
remaining,
configs
);
// 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 = 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;
}
}
remaining = remainingPath;
// If we hadn't matched any segments earlier, use the path as route name
if (routeNames === undefined) {
@@ -150,43 +181,7 @@ export default function getStateFromPath(
}
const state = createNestedStateObject(
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 };
}),
createRouteObjects(configs, routeNames, allParams),
initialRoutes
);
@@ -229,6 +224,46 @@ const joinPaths = (...paths: string[]): string =>
.filter(Boolean)
.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 = (
screen: string,
routeConfig: PathConfig,
@@ -311,7 +346,7 @@ const createConfigItem = (
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
}
return `${escape(it)}\\/`;
return `${it === '*' ? '.*' : escape(it)}\\/`;
})
.join('')})`
)
@@ -433,6 +468,49 @@ const createNestedStateObject = (
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) => {
let current: InitialState | undefined = state;

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.
*/
navigate<RouteName extends keyof ParamList>(
...args: ParamList[RouteName] extends undefined | any
...args: undefined extends ParamList[RouteName]
? [RouteName] | [RouteName, ParamList[RouteName]]
: [RouteName, ParamList[RouteName]]
): void;

View File

@@ -34,6 +34,7 @@ import useStateGetters from './useStateGetters';
import useOnGetState from './useOnGetState';
import useScheduleUpdate from './useScheduleUpdate';
import useCurrentRender from './useCurrentRender';
import isArrayEqual from './isArrayEqual';
// This is to make TypeScript compiler happy
// eslint-disable-next-line babel/no-unused-expressions
@@ -48,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.
*

View File

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

View File

@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
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

View File

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

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
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

View File

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

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
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)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/native",
"description": "React Native integration for React Navigation",
"version": "5.5.0",
"version": "5.5.1",
"keywords": [
"react-native",
"react-navigation",
@@ -33,7 +33,8 @@
"clean": "del lib"
},
"dependencies": {
"@react-navigation/core": "^5.9.0"
"@react-navigation/core": "^5.10.0",
"nanoid": "^3.1.9"
},
"devDependencies": {
"@react-native-community/bob": "^0.14.3",

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

@@ -6,37 +6,226 @@ import {
NavigationState,
getActionFromState,
} from '@react-navigation/core';
import { nanoid } from 'nanoid/non-secure';
import ServerContext from './ServerContext';
import { LinkingOptions } from './types';
type ResultState = ReturnType<typeof getStateFromPathDefault>;
type HistoryState = { index: number };
declare const history: {
state?: HistoryState;
go(delta: number): void;
pushState(state: HistoryState, title: string, url: string): void;
replaceState(state: HistoryState, title: string, url: string): void;
type HistoryRecord = {
// Unique identifier for this record to match it with window.history.state
id: string;
// Navigation state object for the history entry
state: NavigationState;
// Path of the history entry
path: string;
};
const getStateLength = (state: NavigationState) => {
let length = 0;
const createMemoryHistory = () => {
let index = 0;
let items: HistoryRecord[] = [];
if (state.history) {
length = state.history.length;
} else {
length = state.index + 1;
// Whether there's a `history.go(n)` pending
let pending = false;
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) {
// If the focused route has history entries, we need to count them as well
length += getStateLength(focusedState as NavigationState) - 1;
const aRoute = a.routes[a.index];
const bRoute = b.routes[b.index];
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;
@@ -70,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
// 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
@@ -116,203 +307,143 @@ export default function useLinking(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const previousStateLengthRef = React.useRef<number | undefined>(undefined);
const previousHistoryIndexRef = React.useRef(0);
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);
const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
const onPopState = () => {
return history.listen(() => {
const navigation = ref.current;
if (!navigation || !enabled) {
return;
}
const previousHistoryIndex = previousHistoryIndexRef.current;
const historyIndex = history.state?.index ?? 0;
const path = location.pathname + location.search;
previousHistoryIndexRef.current = historyIndex;
pendingPopStatePathRef.current = path;
if (pendingIndexChangeRef.current === historyIndex) {
pendingIndexChangeRef.current = undefined;
// When browser back/forward is clicked, we first need to check if state object for this index exists
// 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;
}
const state = navigation.getRootState();
const path = getPathFromStateRef.current(state, configRef.current);
const state = getStateFromPathRef.current(path, configRef.current);
let canGoBack = true;
let numberOfBacks = 0;
if (state) {
const action = getActionFromState(state);
if (previousHistoryIndex === historyIndex) {
if (location.pathname + location.search !== path) {
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();
}
if (action !== undefined) {
navigation.dispatch(action);
} 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) {
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]);
});
}, [enabled, history, ref]);
React.useEffect(() => {
if (!enabled) {
return;
}
if (ref.current && previousStateLengthRef.current === undefined) {
previousStateLengthRef.current = getStateLength(
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();
if (ref.current) {
// We need to record the current metadata on the first render if they aren't set
// This will allow the initial state to be in the history entry
const state = ref.current.getRootState();
const path = getPathFromStateRef.current(state, configRef.current);
const previousStateLength = previousStateLengthRef.current ?? 1;
const stateLength = getStateLength(state);
if (pendingStateMultiUpdateRef.current) {
if (location.pathname + location.search === path) {
pendingStateMultiUpdateRef.current = false;
} else {
return;
}
if (previousStateRef.current === undefined) {
previousStateRef.current = state;
}
previousStateLengthRef.current = stateLength;
history.replace({ path, state });
}
if (
pendingStateUpdateRef.current &&
location.pathname + location.search === path
) {
pendingStateUpdateRef.current = false;
const onStateChange = async () => {
const navigation = ref.current;
if (!navigation || !enabled) {
return;
}
let index = history.state?.index ?? 0;
const previousState = previousStateRef.current;
const state = navigation.getRootState();
if (previousStateLength === stateLength) {
// If no new entries were added to history in our navigation state, we want to replaceState
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);
}
const pendingPath = pendingPopStatePathRef.current;
const path = getPathFromStateRef.current(state, configRef.current);
previousHistoryIndexRef.current = index;
} else if (previousStateLength > stateLength) {
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
);
previousStateRef.current = state;
pendingPopStatePathRef.current = undefined;
if (delta > 0) {
// We need to set this to ignore the `popstate` event
pendingIndexChangeRef.current = index - delta;
// To detect the kind of state change, we need to:
// - Find the common focused navigation state in previous and current state
// - 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
history.go(-delta);
} else {
// We're not going back in history, but the navigation state changed
// The URL probably also changed, so we need to re-sync the URL
if (location.pathname + location.search !== path) {
history.replaceState({ index }, '', path);
previousHistoryIndexRef.current = index;
if (
previousFocusedState &&
focusedState &&
// We should only handle push/pop if path changed from what was in last `popstate`
// Otherwise it's likely a change triggered by `popstate`
path !== pendingPath
) {
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 {

View File

@@ -3,6 +3,46 @@
All notable changes to this project will be documented in this file.
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)

View File

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

View File

@@ -133,7 +133,8 @@ export type StackHeaderOptions = {
*/
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;
/**

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 (
<GestureHandlerRefContext.Provider value={gestureRef}>
<PanGestureHandlerNative {...props} />
<PanGestureHandlerNative {...props} ref={gestureRef} />
</GestureHandlerRefContext.Provider>
);
}

View File

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

View File

@@ -493,6 +493,12 @@ export default class Card extends React.Component<Props> {
? Color(backgroundColor).alpha() === 0
: 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 (
<CardAnimationContext.Provider value={animationContext}>
<View pointerEvents="box-none" {...rest}>
@@ -502,7 +508,12 @@ export default class Card extends React.Component<Props> {
</View>
) : null}
<Animated.View
style={[styles.container, containerStyle, customContainerStyle]}
style={[
styles.container,
dummyStyle,
containerStyle,
customContainerStyle,
]}
pointerEvents="box-none"
>
<PanGestureHandler

View File

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

View File

@@ -19,6 +19,7 @@ import {
} from '../../TransitionConfigs/TransitionPresets';
import { forNoAnimation as forNoAnimationHeader } from '../../TransitionConfigs/HeaderStyleInterpolators';
import { forNoAnimation as forNoAnimationCard } from '../../TransitionConfigs/CardStyleInterpolators';
import HeaderShownContext from '../../utils/HeaderShownContext';
import getDistanceForDirection from '../../utils/getDistanceForDirection';
import {
Layout,
@@ -385,180 +386,224 @@ export default class CardStack extends React.Component<Props, State> {
const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal';
return (
<React.Fragment>
<MaybeScreenContainer
enabled={isScreensEnabled}
style={styles.container}
onLayout={this.handleLayout}
>
{routes.map((route, index, self) => {
const focused = focusedRoute.key === route.key;
const gesture = gestures[route.key];
const scene = scenes[index];
<HeaderShownContext.Consumer>
{(isParentHeaderShown) => {
const isFloatHeaderAbsolute =
headerMode === 'float'
? this.state.scenes.slice(-2).some((scene) => {
const { descriptor } = scene;
const options = descriptor ? descriptor.options : {};
const {
headerTransparent,
headerShown = isParentHeaderShown === false,
} = options;
const isScreenActive = scene.progress.next
? scene.progress.next.interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
if (headerTransparent || headerShown === false) {
return true;
}
return false;
})
: 1;
: false;
const {
safeAreaInsets,
headerShown,
headerTransparent,
cardShadowEnabled,
cardOverlayEnabled,
cardOverlay,
cardStyle,
animationEnabled,
gestureResponseDistance,
gestureVelocityImpact,
gestureDirection = defaultTransitionPreset.gestureDirection,
transitionSpec = defaultTransitionPreset.transitionSpec,
cardStyleInterpolator = animationEnabled === false
? forNoAnimationCard
: defaultTransitionPreset.cardStyleInterpolator,
headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
} = scene.descriptor
? scene.descriptor.options
: ({} as StackNavigationOptions);
const floatingHeader =
headerMode === 'float' ? (
<React.Fragment key="header">
{renderHeader({
mode: 'float',
layout,
insets: { top, right, bottom, left },
scenes,
getPreviousRoute,
getFocusedRoute: this.getFocusedRoute,
onContentHeightChange: this.handleHeaderLayout,
gestureDirection:
focusedOptions.gestureDirection !== undefined
? focusedOptions.gestureDirection
: defaultTransitionPreset.gestureDirection,
styleInterpolator:
focusedOptions.headerStyleInterpolator !== undefined
? focusedOptions.headerStyleInterpolator
: defaultTransitionPreset.headerStyleInterpolator,
style: [
styles.floating,
isFloatHeaderAbsolute && styles.absolute,
],
})}
</React.Fragment>
) : null;
let transitionConfig = {
gestureDirection,
transitionSpec,
cardStyleInterpolator,
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}
return (
<React.Fragment>
{isFloatHeaderAbsolute ? null : floatingHeader}
<MaybeScreenContainer
enabled={isScreensEnabled}
active={isScreenActive}
pointerEvents="box-none"
style={styles.container}
onLayout={this.handleLayout}
>
<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={headerHeights[route.key]}
onHeaderHeightChange={this.handleHeaderLayout}
getPreviousRoute={getPreviousRoute}
getFocusedRoute={this.getFocusedRoute}
mode={mode}
headerMode={headerMode}
headerShown={headerShown}
headerTransparent={headerTransparent}
renderHeader={renderHeader}
renderScene={renderScene}
onOpenRoute={onOpenRoute}
onCloseRoute={onCloseRoute}
onTransitionStart={onTransitionStart}
onTransitionEnd={onTransitionEnd}
gestureEnabled={index !== 0 && getGesturesEnabled({ route })}
gestureVelocityImpact={gestureVelocityImpact}
{...transitionConfig}
/>
</MaybeScreen>
);
})}
</MaybeScreenContainer>
{headerMode === 'float'
? renderHeader({
mode: 'float',
layout,
insets: { top, right, bottom, left },
scenes,
getPreviousRoute,
getFocusedRoute: this.getFocusedRoute,
onContentHeightChange: this.handleHeaderLayout,
gestureDirection:
focusedOptions.gestureDirection !== undefined
? focusedOptions.gestureDirection
: defaultTransitionPreset.gestureDirection,
styleInterpolator:
focusedOptions.headerStyleInterpolator !== undefined
? focusedOptions.headerStyleInterpolator
: defaultTransitionPreset.headerStyleInterpolator,
style: styles.floating,
})
: null}
</React.Fragment>
{routes.map((route, index, self) => {
const focused = focusedRoute.key === route.key;
const gesture = gestures[route.key];
const scene = scenes[index];
const isScreenActive = scene.progress.next
? scene.progress.next.interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
})
: 1;
const {
safeAreaInsets,
headerShown = isParentHeaderShown === false,
headerTransparent,
cardShadowEnabled,
cardOverlayEnabled,
cardOverlay,
cardStyle,
animationEnabled,
gestureResponseDistance,
gestureVelocityImpact,
gestureDirection = defaultTransitionPreset.gestureDirection,
transitionSpec = defaultTransitionPreset.transitionSpec,
cardStyleInterpolator = animationEnabled === false
? forNoAnimationCard
: defaultTransitionPreset.cardStyleInterpolator,
headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
} = scene.descriptor
? scene.descriptor.options
: ({} as StackNavigationOptions);
let transitionConfig = {
gestureDirection,
transitionSpec,
cardStyleInterpolator,
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;
}
}
}
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>
);
}
}
@@ -567,10 +612,13 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
floating: {
absolute: {
position: 'absolute',
top: 0,
left: 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> }) => {
const { state, navigation } = this.props;
const { closingRouteKeys, replacingRouteKeys } = this.state;
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.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
navigation.navigate(route);
} else {
@@ -409,6 +411,9 @@ export default class StackView extends React.Component<Props, State> {
navigation,
keyboardHandlingEnabled,
mode = 'card',
headerMode = mode === 'card' && Platform.OS === 'ios'
? 'float'
: 'screen',
...rest
} = this.props;
@@ -419,9 +424,6 @@ export default class StackView extends React.Component<Props, State> {
closingRouteKeys,
} = this.state;
const headerMode =
mode === 'card' && Platform.OS === 'ios' ? 'float' : 'screen';
return (
<NavigationHelpersContext.Provider value={navigation}>
<GestureHandlerWrapper style={styles.container}>

View File

@@ -13506,6 +13506,11 @@ nanoid@^3.1.5:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.5.tgz#56da1bb76b619391fc61625e8b4e4bff309b9942"
integrity sha512-77yYm8wPy8igTpUQv9fA0VzEb5Ohxt5naC3zTK1oAb+u1MiyITtx0jpYrYRFfgJlefwJy2SkCaojZvxSYq6toA==
nanoid@^3.1.9:
version "3.1.9"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.9.tgz#1f148669c70bb2072dc5af0666e46edb6cd31fb2"
integrity sha512-fFiXlFo4Wkuei3i6w9SQI6yuzGRTGi8Z2zZKZpUxv/bQlBi4jtbVPBSNFZHQA9PNjofWqtIa8p+pnsc0kgZrhQ==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"