Compare commits

..

35 Commits
2.6.0 ... 2.9.1

Author SHA1 Message Date
Brent Vatne
67233dc9ef Release 2.9.1 2018-07-24 10:49:56 -07:00
Brent Vatne
b0443c1861 Move more logs behind debug flag in stack playground 2018-07-24 10:47:59 -07:00
Brent Vatne
c0b637df52 Fix title offfset calculation 2018-07-24 10:47:02 -07:00
Brent Vatne
9a82706fba Fix snapshots 2018-07-20 14:44:26 -07:00
Brent Vatne
d973a26edb Release 2.9.0 2018-07-20 14:33:27 -07:00
Brent Vatne
852e7e1974 Respect custom background color in header wrapper 2018-07-20 14:30:38 -07:00
Brent Vatne
cd3707d64b Add headerLayoutPreset, add config for back button title visibility and make it have reasonable defaults, better back button ripple on Android (#4588) 2018-07-20 14:12:39 -07:00
Brent Vatne
3c36db455f Release 2.8.0 2018-07-19 15:48:09 -07:00
Brent Vatne
ec52c884c5 Update NavigationPlayground to Expo SDK 28 2018-07-19 15:46:24 -07:00
Brent Vatne
c4b3f25a0f Cleanup unused descriptors and handle the case where we might expect to have a descriptor but do not (#4723) 2018-07-19 13:16:38 -07:00
Eric Vicenti
93642e16e7 Fix createNavigator leak of old descriptors 2018-07-19 12:41:14 -07:00
Eric Vicenti
1a76556290 Fix leak in createNavigator
Previous descriptors had been retained because this binding caused `this.prevState` to remain referenced. This binds the component getter to null instead.
2018-07-19 12:21:22 -07:00
Reza Ghorbani
12b21f052e added header container styles to be customized (#4331) 2018-07-18 15:14:08 -07:00
Brent Vatne
c1f07dc167 Release 2.7.0 2018-07-17 15:50:18 -07:00
Brent Vatne
bc04b31d01 Add border for transparent header in example 2018-07-17 15:43:22 -07:00
Eric Vicenti
35307c70be Improve empty path and param handling (#4671)
* Overhaul Path handling

* Another test for deep link
2018-07-17 13:51:20 -07:00
Brent Vatne
7e3f4f3bec Fix tests 2018-07-17 13:49:12 -07:00
Brent Vatne
cbd0958e6f Remove unnecessary style array 2018-07-17 13:36:16 -07:00
Brent Vatne
cab4d71a5e Fix edge case where route was in nav state but never actually graduated to scene 2018-07-17 13:36:16 -07:00
Kenza Iraki
108ac0e2a9 Set borderBottom to transparent and of size 0 if headerTransparent is true (#4701) 2018-07-16 16:32:14 -07:00
Brent Vatne
fa4fdb9c57 Fix onTransitionStart config not being invoked with keyboard aware navigator, and use prop over config when available 2018-07-16 15:13:25 -07:00
Brent Vatne
ebdd2da79f Pull onTransitionStart from navigationConfig, fixes #4100 2018-07-16 14:55:34 -07:00
Matteo Codogno
1fe11c100e Fix #4608 - remove header left component only when a headerLeft optio… (#4679)
* Fix #4608 - remove header left component only when a headerLeft option is not specified

* Update CHANGELOG.md
2018-07-12 11:42:56 -07:00
Eric Vicenti
c4b84f1d66 Fix container referene to startup state data 2018-07-12 11:01:11 -07:00
Michael Lefkowitz
69f394be5b Feat/allow keyless replace (#4636)
* allow key to be undefined on StackNavigation.replace method

* added tests for replace action w/out key

* fix typo

* updated changelog

* updated teests for clarity

* added length check on routes to safely fallthrough to search
2018-07-10 23:41:42 -07:00
Eric Vicenti
316e4991ac Add enableURLHandling to navigation container 2018-07-10 14:47:57 -07:00
Ashoat Tevosyan
805064cb5e [flow] Make NavigationRoute types exact (#4667)
React Native 0.56 introduces Flow 0.75, which makes it impossible to refine `NavigationRoute` based on the presence of `index` or `routes` properties.

This PR turns `NavigationStateRoute` and `NavigationLeafRoute` into exact types, which addresses this issue.
2018-07-10 13:59:09 -07:00
Dariusz Łuksza
8f199980cb Fix changelog (#4651)
Fixes 2.6.1 header link
2018-07-06 20:21:00 -07:00
Brent Vatne
37ca6a92ca Release 2.6.2 2018-07-06 10:44:30 -07:00
Brent Vatne
980e0409dc Temporarily remove warnings on vertical padding in header 2018-07-06 10:42:32 -07:00
Brent Vatne
a00ba5918a Default to 0 elevation on transparent header 2018-07-05 15:17:08 -07:00
Brent Vatne
ad6b25cff9 Fix 2.6.1 changelog 2018-07-05 15:07:17 -07:00
Brent Vatne
a69b67d6d2 Release 2.6.1 2018-07-05 15:03:14 -07:00
Brent Vatne
dc436e4d01 Warn for more invalid header styles 2018-07-05 15:02:48 -07:00
Brent Vatne
fe95bdeee6 Fix regression for shadow in header on Android 2018-07-05 14:53:41 -07:00
27 changed files with 1490 additions and 2411 deletions

View File

@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
## [2.9.1] - [2018-07-24](https://github.com/react-navigation/react-navigation/releases/tag/2.9.1)
### Fixed
- Incorrect parameters passed to title offset calculation led to bug in header layout when no right component (https://github.com/react-navigation/react-navigation/issues/4754)
## [2.9.0] - [2018-07-20](https://github.com/react-navigation/react-navigation/releases/tag/2.9.0)
### Added
- `headerLayoutPreset: 'center' | 'left'` to provide an easy solution for [questions like this](https://github.com/react-navigation/react-navigation/issues/4615).
- `headerBackTitleEnabled` - this configuration option for stack navigator allows you to force back button titles to either be rendered or not (if you disagree with defaults for your platform and layout preset).
### Fixed
- Android back button ripple is now appropriately sized (fixes [#3955](https://github.com/react-navigation/react-navigation/issues/3955)).
- Respect header background color on container (fixes edge case where user depended on displaying content that was rendered behind the navigator, this particular behavior should not be depended on and may break in the future, but this change is still useful regardless).
## [2.8.0] - [2018-07-19](https://github.com/react-navigation/react-navigation/releases/tag/2.8.0)
### Added
- `headerLeftContainerStyle`, `headerTitleContainerStyle`, and `headerRightContainerStyle` are exposed on `navigationOptions`. These properties allow you to customize the style of the container of `headerLeft`, `headerTitle` and `headerRight` components.
### Fixed
- Fixed memory leaks in `createNavigator`: [closure scope leak](https://github.com/react-navigation/react-navigation/commit/1a765562905e93bbae0262dd20c2688221c999e8), and [clean up old descriptors](https://github.com/react-navigation/react-navigation/commit/93642e16e7ff029586b68ee732ec790504ee4862).
## [2.7.0] - [2018-07-17](https://github.com/react-navigation/react-navigation/releases/tag/2.7.0)
### Added
- The enableURLHandling prop on the top level navigator component allows you to disable deep linking handling. Currently it is always enabled. To disable it, `<RootNavigator enableURLHandling={false} />`
### Changed
- StackNavigator.replace method no longer requires a key param. If the key is left undefined, the last screen in the stack will be replaced.
### Fixed
- Support headerLeft component for the first screen in a stack (#4608).
- Removed bottomBorder when `headerTransparent` is set to true.
- Improve empty path and param handling in deep linking (#4671). This fixes issues with deep linking and fully tests the differences between path: '' and path: null. Empty string matches empty paths, and null path will let the child router handle paths at the same level. Also it makes sure that params are not duplicated between path and query when they are serialized with getPathAndParamsForState.
- Fix onTransitionStart not being invoked when provided in navigator config.(#4100)
- Rare case when users navigated back and forth quickly with exactly the right timing would cause a crash due to a scene being queued to transition, then clobbered, then attempted to render as a stale scene but without a descriptor. ([commit](https://github.com/react-navigation/react-navigation/commit/cab4d71a5e09188df3f4a294c98779eecb860a78))
## [2.6.2] - [2018-07-06](https://github.com/react-navigation/react-navigation/releases/tag/2.6.2)
### Changed
- Relax vertical padding warnings on header.
## [2.6.1] - [2018-07-05](https://github.com/react-navigation/react-navigation/releases/tag/2.6.1)
### Added
- Warn for more invalid headerStyle properties (padding, top/right/bottom/left, position).
### Fixed
- Fixed missing header shadow on Android.
## [2.6.0] - [2018-07-04](https://github.com/react-navigation/react-navigation/releases/tag/2.6.0)
### Added
- [NavigationEvents](https://github.com/react-navigation/react-navigation/pull/4188) component as a declarative interface for subscribing to navigation focus events.
@@ -58,7 +104,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Changed
- Improved examples
[Unreleased]: https://github.com/react-navigation/react-navigation/compare/2.6.0...HEAD
[Unreleased]: https://github.com/react-navigation/react-navigation/compare/2.9.1...HEAD
[2.9.1]: https://github.com/react-navigation/react-navigation/compare/2.9.0...2.9.1
[2.9.0]: https://github.com/react-navigation/react-navigation/compare/2.8.0...2.9.0
[2.8.0]: https://github.com/react-navigation/react-navigation/compare/2.7.0...2.8.0
[2.7.0]: https://github.com/react-navigation/react-navigation/compare/2.6.2...2.7.0
[2.6.2]: https://github.com/react-navigation/react-navigation/compare/2.6.1...2.6.2
[2.6.1]: https://github.com/react-navigation/react-navigation/compare/2.6.0...2.6.1
[2.6.0]: https://github.com/react-navigation/react-navigation/compare/2.5.5...2.6.0
[2.5.5]: https://github.com/react-navigation/react-navigation/compare/2.5.4...2.5.5
[2.5.4]: https://github.com/react-navigation/react-navigation/compare/2.5.3...2.5.4

View File

@@ -11,8 +11,7 @@
"splash": {
"image": "./assets/icons/splash.png"
},
"sdkVersion": "27.0.0",
"entryPoint": "./node_modules/react-native-scripts/build/bin/crna-entry.js",
"sdkVersion": "28.0.0",
"assetBundlePatterns": [
"**/*"
],

View File

@@ -10,7 +10,7 @@ import type {
} from 'react-navigation';
import * as React from 'react';
import { ScrollView, StatusBar } from 'react-native';
import { Platform, ScrollView, StatusBar } from 'react-native';
import {
createStackNavigator,
SafeAreaView,
@@ -24,6 +24,8 @@ import SampleText from './SampleText';
import { Button } from './commonComponents/ButtonWithMargin';
import { HeaderButtons } from './commonComponents/HeaderButtons';
const DEBUG = false;
type MyNavScreenProps = {
navigation: NavigationScreenProp<NavigationState>,
banner: React.Node,
@@ -133,16 +135,16 @@ class MyHomeScreen extends React.Component<MyHomeScreenProps> {
this._s3.remove();
}
_onWF = a => {
console.log('_willFocus HomeScreen', a);
DEBUG && console.log('_willFocus HomeScreen', a);
};
_onDF = a => {
console.log('_didFocus HomeScreen', a);
DEBUG && console.log('_didFocus HomeScreen', a);
};
_onWB = a => {
console.log('_willBlur HomeScreen', a);
DEBUG && console.log('_willBlur HomeScreen', a);
};
_onDB = a => {
console.log('_didBlur HomeScreen', a);
DEBUG && console.log('_didBlur HomeScreen', a);
};
render() {
@@ -177,16 +179,16 @@ class MyPhotosScreen extends React.Component<MyPhotosScreenProps> {
this._s3.remove();
}
_onWF = a => {
console.log('_willFocus PhotosScreen', a);
DEBUG && console.log('_willFocus PhotosScreen', a);
};
_onDF = a => {
console.log('_didFocus PhotosScreen', a);
DEBUG && console.log('_didFocus PhotosScreen', a);
};
_onWB = a => {
console.log('_willBlur PhotosScreen', a);
DEBUG && console.log('_willBlur PhotosScreen', a);
};
_onDB = a => {
console.log('_didBlur PhotosScreen', a);
DEBUG && console.log('_didBlur PhotosScreen', a);
};
render() {
@@ -231,18 +233,23 @@ MyProfileScreen.navigationOptions = props => {
};
};
const SimpleStack = createStackNavigator({
Home: {
screen: MyHomeScreen,
const SimpleStack = createStackNavigator(
{
Home: {
screen: MyHomeScreen,
},
Profile: {
path: 'people/:name',
screen: MyProfileScreen,
},
Photos: {
path: 'photos/:name',
screen: MyPhotosScreen,
},
},
Profile: {
path: 'people/:name',
screen: MyProfileScreen,
},
Photos: {
path: 'photos/:name',
screen: MyPhotosScreen,
},
});
{
// headerLayoutPreset: 'center',
}
);
export default SimpleStack;

View File

@@ -16,6 +16,7 @@ import {
Platform,
ScrollView,
StatusBar,
StyleSheet,
View,
} from 'react-native';
import { Header, createStackNavigator } from 'react-navigation';
@@ -231,6 +232,10 @@ const StackWithTranslucentHeader = createStackNavigator(
headerTransitionPreset: 'uikit',
navigationOptions: {
headerTransparent: true,
headerStyle: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#A7A7AA',
},
headerBackground: Platform.select({
ios: <BlurView style={{ flex: 1 }} intensity={98} />,
android: (

View File

@@ -2,23 +2,23 @@
"name": "NavigationPlayground",
"version": "0.1.0",
"private": true,
"main": "./node_modules/react-native-scripts/build/bin/crna-entry.js",
"main": "node_modules/expo/AppEntry.js",
"private": true,
"scripts": {
"start": "react-native-scripts start",
"eject": "react-native-scripts eject",
"android": "react-native-scripts android",
"ios": "react-native-scripts ios",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"test": "flow"
},
"dependencies": {
"expo": "^27.0.0",
"expo": "^28.0.0",
"invariant": "^2.2.4",
"react": "16.3.1",
"react-native": "^0.55.0",
"react-native-iphone-x-helper": "^1.0.2",
"react-navigation": "link:../..",
"react-navigation-header-buttons": "^0.0.4",
"react-navigation-material-bottom-tabs": "0.1.3",
"react-navigation-material-bottom-tabs": "^0.3.0",
"react-navigation-tabs": "^0.5.1"
},
"devDependencies": {
@@ -26,9 +26,8 @@
"babel-plugin-transform-remove-console": "^6.9.0",
"flow-bin": "^0.67.0",
"jest": "^22.1.3",
"jest-expo": "^26.0.0",
"react-native-scripts": "^1.5.0",
"react-test-renderer": "16.3.0-alpha.1"
"jest-expo": "^28.0.0",
"react-test-renderer": "16.3.1"
},
"jest": {
"preset": "jest-expo",

File diff suppressed because it is too large Load Diff

View File

@@ -184,7 +184,7 @@ declare module 'react-navigation' {
| NavigationLeafRoute
| NavigationStateRoute;
declare export type NavigationLeafRoute = {
declare export type NavigationLeafRoute = {|
/**
* React's key used by some navigators. No need to specify these manually,
* they will be defined by the router.
@@ -204,10 +204,12 @@ declare module 'react-navigation' {
* e.g. `{ car_id: 123 }` in a route that displays a car.
*/
params?: NavigationParams,
};
|};
declare export type NavigationStateRoute = NavigationLeafRoute &
NavigationState;
declare export type NavigationStateRoute = {|
...NavigationLeafRoute,
...$Exact<NavigationState>,
|};
/**
* Router

View File

@@ -1,6 +1,6 @@
{
"name": "react-navigation",
"version": "2.6.0",
"version": "2.9.1",
"description": "Routing and navigation for your React Native apps",
"main": "src/react-navigation.js",
"repository": {

View File

@@ -130,7 +130,11 @@ export default function createNavigationContainer(Component) {
}
_handleOpenURL = ({ url }) => {
const parsedUrl = urlToPathAndParams(url, this.props.uriPrefix);
const { enableURLHandling, uriPrefix } = this.props;
if (enableURLHandling === false) {
return;
}
const parsedUrl = urlToPathAndParams(url, uriPrefix);
if (parsedUrl) {
const { path, params } = parsedUrl;
const action = Component.router.getActionForPathAndParams(path, params);
@@ -199,11 +203,15 @@ export default function createNavigationContainer(Component) {
Linking.addEventListener('url', this._handleOpenURL);
// Pull out anything that can impact state
const { persistenceKey, uriPrefix } = this.props;
const startupStateJSON =
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
const url = await Linking.getInitialURL();
const parsedUrl = url && urlToPathAndParams(url, uriPrefix);
const { persistenceKey, uriPrefix, enableURLHandling } = this.props;
let parsedUrl = null;
let startupStateJSON = null;
if (enableURLHandling !== false) {
startupStateJSON =
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
const url = await Linking.getInitialURL();
parsedUrl = url && urlToPathAndParams(url, uriPrefix);
}
// Initialize state. This must be done *after* any async code
// so we don't end up with a different value for this.state.nav

View File

@@ -4,11 +4,9 @@ exports[`Nested navigators renders succesfully as direct child 1`] = `
<View
onLayout={[Function]}
style={
Array [
Object {
"flex": 1,
},
]
Object {
"flex": 1,
}
}
>
<View
@@ -77,11 +75,9 @@ exports[`Nested navigators renders succesfully as direct child 1`] = `
<View
onLayout={[Function]}
style={
Array [
Object {
"flex": 1,
},
]
Object {
"flex": 1,
}
}
>
<View

View File

@@ -4,11 +4,9 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
<View
onLayout={[Function]}
style={
Array [
Object {
"flex": 1,
},
]
Object {
"flex": 1,
}
}
>
<View
@@ -83,7 +81,7 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
collapsable={undefined}
style={
Object {
"backgroundColor": "#F7F7F7",
"backgroundColor": "red",
"transform": Array [
Object {
"translateX": 0,
@@ -210,11 +208,9 @@ exports[`StackNavigator renders successfully 1`] = `
<View
onLayout={[Function]}
style={
Array [
Object {
"flex": 1,
},
]
Object {
"flex": 1,
}
}
>
<View
@@ -289,7 +285,7 @@ exports[`StackNavigator renders successfully 1`] = `
collapsable={undefined}
style={
Object {
"backgroundColor": "#F7F7F7",
"backgroundColor": "red",
"transform": Array [
Object {
"translateX": 0,

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { TextInput } from 'react-native';
export default Navigator =>
export default (Navigator, navigatorConfig) =>
class KeyboardAwareNavigator extends React.Component {
static router = Navigator.router;
_previouslyFocusedTextInput = null;
@@ -49,7 +49,9 @@ export default Navigator =>
}
}
this.props.onTransitionStart &&
this.props.onTransitionStart(transitionProps, prevTransitionProps);
const onTransitionStart =
this.props.onTransitionStart || navigatorConfig.onTransitionStart;
onTransitionStart &&
onTransitionStart(transitionProps, prevTransitionProps);
};
};

View File

@@ -24,7 +24,7 @@ function createNavigator(NavigatorView, router, navigationConfig) {
);
}
const descriptors = { ...prevState.descriptors };
const descriptors = {};
routes.forEach(route => {
if (
@@ -36,8 +36,10 @@ function createNavigator(NavigatorView, router, navigationConfig) {
descriptors[route.key] = prevDescriptors[route.key];
return;
}
const getComponent = () =>
router.getComponentForRouteName(route.routeName);
const getComponent = router.getComponentForRouteName.bind(
null,
route.routeName
);
const childNavigation = navigation.getChildNavigation(route.key);
const options = router.getScreenOptions(childNavigation, screenProps);
descriptors[route.key] = {

View File

@@ -28,7 +28,7 @@ function createStackNavigator(routeConfigMap, stackConfig = {}) {
// Create a navigator with StackView as the view
let Navigator = createNavigator(StackView, router, stackConfig);
if (!disableKeyboardHandling) {
Navigator = createKeyboardAwareNavigator(Navigator);
Navigator = createKeyboardAwareNavigator(Navigator, stackConfig);
}
return Navigator;

View File

@@ -107,13 +107,7 @@ export default (routeConfigs, stackConfig = {}) => {
const {
getPathAndParamsForRoute,
getActionForPathAndParams,
} = createPathParser(
childRouters,
routeConfigs,
stackConfig.paths,
initialRouteName,
initialRouteParams
);
} = createPathParser(childRouters, routeConfigs, stackConfig.paths);
return {
childRouters,
@@ -397,7 +391,15 @@ export default (routeConfigs, stackConfig = {}) => {
// Handle replace action
if (action.type === StackActions.REPLACE) {
const routeIndex = state.routes.findIndex(r => r.key === action.key);
let routeIndex;
// If the key param is undefined, set the index to the last route in the stack
if (action.key === undefined && state.routes.length) {
routeIndex = state.routes.length - 1;
} else {
routeIndex = state.routes.findIndex(r => r.key === action.key);
}
// Only replace if the key matches one of our routes
if (routeIndex !== -1) {
const childRouter = childRouters[action.routeName];

View File

@@ -47,13 +47,7 @@ export default (routeConfigs, config = {}) => {
const {
getPathAndParamsForRoute,
getActionForPathAndParams,
} = createPathParser(
childRouters,
routeConfigs,
config.paths,
initialRouteName,
initialRouteParams
);
} = createPathParser(childRouters, routeConfigs, config.paths);
if (initialRouteIndex === -1) {
throw new Error(

View File

@@ -13,50 +13,50 @@ beforeEach(() => {
_TESTING_ONLY_normalize_keys();
});
const ListScreen = () => <div />;
const performRouterTest = createTestRouter => {
const ListScreen = () => <div />;
const ProfileNavigator = () => <div />;
ProfileNavigator.router = StackRouter({
list: {
path: 'list/:id',
screen: ListScreen,
},
});
const MainNavigator = () => <div />;
MainNavigator.router = StackRouter({
profile: {
path: 'p/:id',
screen: ProfileNavigator,
},
});
const LoginScreen = () => <div />;
const AuthNavigator = () => <div />;
AuthNavigator.router = StackRouter({
login: {
screen: LoginScreen,
},
});
const BarScreen = () => <div />;
class FooNavigator extends React.Component {
static router = StackRouter({
bar: {
path: 'b/:barThing',
screen: BarScreen,
const ProfileNavigator = () => <div />;
ProfileNavigator.router = StackRouter({
list: {
path: 'list/:id',
screen: ListScreen,
},
});
render() {
return <div />;
const MainNavigator = () => <div />;
MainNavigator.router = StackRouter({
profile: {
path: 'p/:id',
screen: ProfileNavigator,
},
});
const LoginScreen = () => <div />;
const AuthNavigator = () => <div />;
AuthNavigator.router = StackRouter({
login: {
screen: LoginScreen,
},
});
const BarScreen = () => <div />;
class FooNavigator extends React.Component {
static router = StackRouter({
bar: {
path: 'b/:barThing',
screen: BarScreen,
},
});
render() {
return <div />;
}
}
}
const PersonScreen = () => <div />;
const PersonScreen = () => <div />;
const performRouterTest = createTestRouter => {
const testRouter = createTestRouter({
main: {
screen: MainNavigator,
@@ -78,7 +78,7 @@ const performRouterTest = createTestRouter => {
},
});
test('Handles empty URIs', () => {
test('Handles empty URIs with empty action', () => {
const router = createTestRouter(
{
Foo: {
@@ -91,12 +91,8 @@ const performRouterTest = createTestRouter => {
{ initialRouteName: 'Bar', initialRouteParams: { foo: 42 } }
);
const action = router.getActionForPathAndParams('');
expect(action).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
params: { foo: 42 },
});
const state = router.getStateForAction(action);
expect(action).toEqual(null);
const state = router.getStateForAction(action || NavigationActions.init());
expect(state.routes[state.index]).toEqual(
expect.objectContaining({
routeName: 'Bar',
@@ -105,6 +101,244 @@ const performRouterTest = createTestRouter => {
);
});
test('Handles paths with several params', () => {
const router = createTestRouter({
Person: {
path: 'people/:person',
screen: () => <div />,
},
Task: {
path: 'people/:person/tasks/:task',
screen: () => <div />,
},
ThingA: {
path: 'things/:good',
screen: () => <div />,
},
Thing: {
path: 'things/:good/:thing',
screen: () => <div />,
},
});
const action = router.getActionForPathAndParams(
'people/brent/tasks/everything'
);
expect(action).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Task',
params: { person: 'brent', task: 'everything' },
});
const action1 = router.getActionForPathAndParams('people/lucy');
expect(action1).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Person',
params: { person: 'lucy' },
});
const action2 = router.getActionForPathAndParams('things/foo/bar');
expect(action2).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Thing',
params: { good: 'foo', thing: 'bar' },
});
const action3 = router.getActionForPathAndParams('things/foo');
expect(action3).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'ThingA',
params: { good: 'foo' },
});
});
test('Handles empty path configuration', () => {
const router = createTestRouter({
Foo: {
screen: () => <div />,
},
Bar: {
screen: () => <div />,
path: '',
},
});
const action = router.getActionForPathAndParams('');
expect(action).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
params: {},
});
});
test('Handles wildcard path configuration', () => {
const router = createTestRouter({
Foo: {
screen: () => <div />,
},
Bar: {
screen: () => <div />,
path: ':something',
},
});
const action = router.getActionForPathAndParams('');
expect(action).toEqual(null);
const action1 = router.getActionForPathAndParams('Foo');
expect(action1).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Foo',
params: {},
});
const action2 = router.getActionForPathAndParams('asdf');
expect(action2).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
params: { something: 'asdf' },
});
});
test('Null path behavior', () => {
const ScreenA = () => <div />;
const router = createTestRouter({
Bar: {
screen: ScreenA,
},
Foo: {
path: null,
screen: ScreenA,
},
Baz: {
path: '',
screen: ScreenA,
},
});
const action0 = router.getActionForPathAndParams('test/random', {});
expect(action0).toBe(null);
const action1 = router.getActionForPathAndParams('', {});
expect(action1.routeName).toBe('Baz');
const state1 = router.getStateForAction(action1);
expect(state1.routes[state1.index].routeName).toBe('Baz');
});
test('Multiple null path sub routers path behavior', () => {
const ScreenA = () => <div />;
const ScreenB = () => <div />;
ScreenB.router = createTestRouter({
Foo: ScreenA,
});
const ScreenC = () => <div />;
ScreenC.router = createTestRouter({
Bar: {
path: 'bar/:id',
screen: ScreenA,
},
Empty: {
path: '',
screen: ScreenA,
},
});
const router = createTestRouter({
A: {
screen: ScreenA,
},
B: {
path: null,
screen: ScreenB,
},
C: {
path: null,
screen: ScreenC,
},
});
const action0 = router.getActionForPathAndParams('Foo', {});
expect(action0.routeName).toBe('B');
expect(action0.action.routeName).toBe('Foo');
const action1 = router.getActionForPathAndParams('', {});
expect(action1.routeName).toBe('C');
expect(action1.action.routeName).toBe('Empty');
const action2 = router.getActionForPathAndParams('A', {});
expect(action2.routeName).toBe('A');
const action3 = router.getActionForPathAndParams('bar/asdf', {});
expect(action3.routeName).toBe('C');
expect(action3.action.routeName).toBe('Bar');
expect(action3.action.params.id).toBe('asdf');
});
test('Null and empty string path sub routers behavior', () => {
const ScreenA = () => <div />;
const ScreenB = () => <div />;
ScreenB.router = createTestRouter({
Foo: ScreenA,
Baz: {
screen: ScreenA,
path: '',
},
});
const ScreenC = () => <div />;
ScreenC.router = createTestRouter({
Boo: ScreenA,
Bar: ScreenA,
Baz: {
screen: ScreenA,
path: '',
},
});
const router = createTestRouter({
B: {
path: null,
screen: ScreenB,
},
C: {
path: '',
screen: ScreenC,
},
});
const action0 = router.getActionForPathAndParams('', {});
expect(action0.routeName).toBe('C');
expect(action0.action.routeName).toBe('Baz');
const action1 = router.getActionForPathAndParams('Foo', {});
expect(action1.routeName).toBe('B');
expect(action1.action.routeName).toBe('Foo');
const action2 = router.getActionForPathAndParams('Bar', {});
expect(action2.routeName).toBe('C');
expect(action2.action.routeName).toBe('Bar');
const action3 = router.getActionForPathAndParams('unknown', {});
expect(action3).toBe(null);
});
test('Empty path acts as wildcard for nested router', () => {
const ScreenA = () => <div />;
const Foo = () => <div />;
const ScreenC = () => <div />;
ScreenC.router = createTestRouter({
Boo: ScreenA,
Bar: ScreenA,
});
Foo.router = createTestRouter({
Quo: ScreenA,
Qux: {
screen: ScreenC,
path: '',
},
});
const router = createTestRouter({
Bar: {
screen: ScreenA,
},
Foo,
});
const action0 = router.getActionForPathAndParams('Foo/Bar', {});
expect(action0.routeName).toBe('Foo');
expect(action0.action.routeName).toBe('Qux');
expect(action0.action.action.routeName).toBe('Bar');
});
test('Gets deep path with pure wildcard match', () => {
const ScreenA = () => <div />;
const ScreenB = () => <div />;
@@ -153,7 +387,6 @@ const performRouterTest = createTestRouter => {
const { path, params } = router.getPathAndParamsForState(state);
expect(path).toEqual('baz/321');
expect(params.id).toEqual('123');
expect(params.bazId).toEqual('321');
}
{
@@ -208,6 +441,31 @@ const performRouterTest = createTestRouter => {
});
});
test('URI encoded path param gets parsed and correctly printed', () => {
const router = createTestRouter({
main: {
screen: () => <div />,
},
person: {
path: 'people/:name',
screen: () => <div />,
},
});
const action = testRouter.getActionForPathAndParams('people/Henry%20L');
expect(action).toEqual({
routeName: 'person',
params: {
id: 'Henry L',
},
type: NavigationActions.NAVIGATE,
});
const s = testRouter.getStateForAction(action);
const out = testRouter.getPathAndParamsForState(s);
expect(out.path).toEqual('people/Henry%20L');
expect(out.params).toEqual({});
});
test('Querystring params get passed to nested deep link', () => {
const action = testRouter.getActionForPathAndParams(
'main/p/4/list/10259959195',
@@ -297,3 +555,24 @@ describe('Path handling for stack router', () => {
describe('Path handling for switch router', () => {
performRouterTest(SwitchRouter);
});
test('Handles nested switch routers', () => {
const AScreen = () => <div />;
const DocsNavigator = () => <div />;
DocsNavigator.router = SwitchRouter({
A: AScreen,
B: AScreen,
C: AScreen,
});
DocsNavigator.path = 'docs';
const router = SwitchRouter({
Docs: DocsNavigator,
D: AScreen,
});
const action = router.getActionForPathAndParams('docs/B', {});
expect(action.type).toEqual(NavigationActions.NAVIGATE);
expect(action.routeName).toEqual('Docs');
expect(action.action.type).toEqual(NavigationActions.NAVIGATE);
expect(action.action.routeName).toEqual('B');
});

View File

@@ -319,7 +319,7 @@ describe('StackRouter', () => {
});
test('Correctly returns action chain for partially matched path', () => {
const uri = 'auth/login/2';
const uri = 'auth/login';
const action = TestStackRouter.getActionForPathAndParams(uri);
expect(action).toEqual({
type: NavigationActions.NAVIGATE,
@@ -970,6 +970,77 @@ describe('StackRouter', () => {
expect(replacedState2.routes[0].routeName).toEqual('bar');
});
test('Replace action returns most recent route if no key is provided', () => {
const GrandChildNavigator = () => <div />;
GrandChildNavigator.router = StackRouter({
Quux: { screen: () => <div /> },
Corge: { screen: () => <div /> },
Grault: { screen: () => <div /> },
});
const ChildNavigator = () => <div />;
ChildNavigator.router = StackRouter({
Baz: { screen: () => <div /> },
Woo: { screen: () => <div /> },
Qux: { screen: GrandChildNavigator },
});
const router = StackRouter({
Foo: { screen: () => <div /> },
Bar: { screen: ChildNavigator },
});
const state = router.getStateForAction({ type: NavigationActions.INIT });
const state2 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
},
state
);
const state3 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Qux',
},
state2
);
const state4 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Corge',
},
state3
);
const state5 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Grault',
},
state4
);
const replacedState = router.getStateForAction(
StackActions.replace({
routeName: 'Woo',
params: { meaning: 42 },
}),
state5
);
const originalCurrentScreen = state5.routes[1].routes[1].routes[2];
const replacedCurrentScreen = replacedState.routes[1].routes[1].routes[2];
expect(replacedState.routes[1].routes[1].index).toEqual(2);
expect(replacedState.routes[1].routes[1].routes.length).toEqual(3);
expect(replacedCurrentScreen.key).not.toEqual(originalCurrentScreen.key);
expect(replacedCurrentScreen.routeName).not.toEqual(
originalCurrentScreen.routeName
);
expect(replacedCurrentScreen.routeName).toEqual('Woo');
expect(replacedCurrentScreen.params.meaning).toEqual(42);
});
test('Handles push transition logic with completion action', () => {
const FooScreen = () => <div />;
const BarScreen = () => <div />;
@@ -1135,8 +1206,7 @@ describe('StackRouter', () => {
};
const { path, params } = router.getPathAndParamsForState(state);
expect(path).toEqual('f/123/baz/321');
expect(params.id).toEqual('123');
expect(params.bazId).toEqual('321');
expect(params).toEqual({});
});
test('Handle goBack identified by key', () => {

View File

@@ -1,5 +1,7 @@
import pathToRegexp from 'path-to-regexp';
import NavigationActions from '../NavigationActions';
import invariant from '../utils/invariant';
const queryString = require('query-string');
function isEmpty(obj) {
@@ -10,6 +12,37 @@ function isEmpty(obj) {
return true;
}
const getParamsFromPath = (inputParams, pathMatch, pathMatchKeys) => {
const params = pathMatch.slice(1).reduce(
// iterate over matched path params
(paramsOut, matchResult, i) => {
const key = pathMatchKeys[i];
if (!key || key.asterisk) {
return paramsOut;
}
const paramName = key.name;
let decodedMatchResult;
try {
decodedMatchResult = decodeURIComponent(matchResult);
} catch (e) {
// ignore `URIError: malformed URI`
}
paramsOut[paramName] = decodedMatchResult || matchResult;
return paramsOut;
},
{
// start with the input(query string) params, which will get overridden by path params
...inputParams,
}
);
return params;
};
const getRestOfPath = (pathMatch, pathMatchKeys) => {
const rest = pathMatch[pathMatchKeys.findIndex(k => k.asterisk) + 1];
return rest;
};
export const urlToPathAndParams = (url, uriPrefix) => {
const searchMatch = url.match(/^(.*)\?(.*)$/);
const params = searchMatch ? queryString.parse(searchMatch[2]) : {};
@@ -34,138 +67,145 @@ export const urlToPathAndParams = (url, uriPrefix) => {
export const createPathParser = (
childRouters,
routeConfigs,
pathConfigs = {},
initialRouteName,
initialRouteParams
pathConfigs = {}
) => {
const pathsByRouteNames = {};
let paths = [];
// Build paths for each route
// Build pathsByRouteNames, which includes a regex to match paths for each route. Keep in mind, the regex will pass for the route and all child routes. The code that uses pathsByRouteNames will need to also verify that the child router produces an action, in the case of isPathMatchable false (a null path).
Object.keys(childRouters).forEach(routeName => {
let pathPattern = pathConfigs[routeName] || routeConfigs[routeName].path;
let matchExact = !!pathPattern && !childRouters[routeName];
if (pathPattern === undefined) {
// If the user hasn't specified a path at all, then we assume the routeName is an appropriate path
pathPattern = routeName;
}
const keys = [];
let re, toPath, priority;
if (typeof pathPattern === 'string') {
// pathPattern may be either a string or a regexp object according to path-to-regexp docs.
re = pathToRegexp(pathPattern, keys);
toPath = pathToRegexp.compile(pathPattern);
priority = 0;
} else if (pathPattern === null) {
// for wildcard match
re = pathToRegexp('*', keys);
toPath = () => '';
matchExact = true;
priority = -1;
}
if (!matchExact) {
const wildcardRe = pathToRegexp(`${pathPattern}/*`, keys);
re = new RegExp(`(?:${re.source})|(?:${wildcardRe.source})`);
}
pathsByRouteNames[routeName] = { re, keys, toPath, priority, pathPattern };
invariant(
pathPattern === null || typeof pathPattern === 'string',
`Route path for ${routeName} must be specified as a string, or null.`
);
// the path may be specified as null, which is similar to empty string because it allows child routers to handle the action, but it will not match empty paths
const isPathMatchable = pathPattern !== null;
// pathPattern is a string with inline params, such as people/:id/*foo
const exactReKeys = [];
const exactRe = isPathMatchable
? pathToRegexp(pathPattern, exactReKeys)
: null;
const extendedPathReKeys = [];
const isWildcard = pathPattern === '' || !isPathMatchable;
const extendedPathRe = pathToRegexp(
isWildcard ? '*' : `${pathPattern}/*`,
extendedPathReKeys
);
pathsByRouteNames[routeName] = {
exactRe,
exactReKeys,
extendedPathRe,
extendedPathReKeys,
isWildcard,
toPath:
pathPattern === null ? () => '' : pathToRegexp.compile(pathPattern),
};
});
paths = Object.entries(pathsByRouteNames);
paths.sort((a, b) => b[1].priority - a[1].priority);
const getActionForPathAndParams = (pathToResolve, inputParams = {}) => {
// If the path is empty (null or empty string)
// just return the initial route action
if (!pathToResolve) {
return NavigationActions.navigate({
routeName: initialRouteName,
params: { ...inputParams, ...initialRouteParams },
});
}
const getActionForPathAndParams = (pathToResolve = '', inputParams = {}) => {
// Attempt to match `pathToResolve` with a route in this router's routeConfigs, deferring to child routers
// Attempt to match `pathToResolve` with a route in this router's
// routeConfigs
let matchedRouteName;
let pathMatch;
let pathMatchKeys;
let matchedAction = null;
// eslint-disable-next-line no-restricted-syntax
for (const [routeName, path] of paths) {
const { re, keys } = path;
pathMatch = re.exec(pathToResolve);
if (pathMatch && pathMatch.length) {
pathMatchKeys = keys;
matchedRouteName = routeName;
break;
}
}
const { exactRe, exactReKeys, extendedPathRe, extendedPathReKeys } = path;
const childRouter = childRouters[routeName];
// We didn't match -- return null to signify no action available
if (!matchedRouteName) {
return null;
}
const exactMatch = exactRe && exactRe.exec(pathToResolve);
// Determine nested actions:
// If our matched route for this router is a child router,
// get the action for the path AFTER the matched path for this
// router
let nestedAction;
if (childRouters[matchedRouteName]) {
nestedAction = childRouters[matchedRouteName].getActionForPathAndParams(
pathMatch.slice(pathMatchKeys.length).join('/'),
inputParams
);
if (!nestedAction) {
return null;
}
}
const params = pathMatch.slice(1).reduce(
// iterate over matched path params
(paramsOut, matchResult, i) => {
const key = pathMatchKeys[i];
if (!key || key.asterisk) {
return paramsOut;
}
const paramName = key.name;
let decodedMatchResult;
try {
decodedMatchResult = decodeURIComponent(matchResult);
} catch (e) {
// ignore `URIError: malformed URI`
if (exactMatch && exactMatch.length) {
const extendedMatch =
extendedPathRe && extendedPathRe.exec(pathToResolve);
let childAction = null;
if (extendedMatch && childRouter) {
const restOfPath = getRestOfPath(extendedMatch, extendedPathReKeys);
childAction = childRouter.getActionForPathAndParams(
restOfPath,
inputParams
);
}
paramsOut[paramName] = decodedMatchResult || matchResult;
return paramsOut;
},
{
// start with the input(query string) params, which will get overridden by path params
...inputParams,
return NavigationActions.navigate({
routeName,
params: getParamsFromPath(inputParams, exactMatch, exactReKeys),
action: childAction,
});
}
);
}
return NavigationActions.navigate({
routeName: matchedRouteName,
...(params ? { params } : {}),
...(nestedAction ? { action: nestedAction } : {}),
});
// eslint-disable-next-line no-restricted-syntax
for (const [routeName, path] of paths) {
const { extendedPathRe, extendedPathReKeys } = path;
const childRouter = childRouters[routeName];
const extendedMatch =
extendedPathRe && extendedPathRe.exec(pathToResolve);
if (extendedMatch && extendedMatch.length) {
const restOfPath = getRestOfPath(extendedMatch, extendedPathReKeys);
let childAction = null;
if (childRouter) {
childAction = childRouter.getActionForPathAndParams(
restOfPath,
inputParams
);
}
if (!childAction) {
continue;
}
return NavigationActions.navigate({
routeName,
params: getParamsFromPath(
inputParams,
extendedMatch,
extendedPathReKeys
),
action: childAction,
});
}
}
return null;
};
const getPathAndParamsForRoute = route => {
const { routeName, params } = route;
const childRouter = childRouters[routeName];
const subPath = pathsByRouteNames[routeName].toPath(params);
const { toPath, exactReKeys } = pathsByRouteNames[routeName];
const subPath = toPath(params);
const nonPathParams = {};
if (params) {
Object.keys(params)
.filter(paramName => !exactReKeys.find(k => k.name === paramName))
.forEach(paramName => {
nonPathParams[paramName] = params[paramName];
});
}
if (childRouter) {
// If it has a router it's a navigator.
// If it doesn't have router it's an ordinary React component.
const child = childRouter.getPathAndParamsForState(route);
return {
path: subPath ? `${subPath}/${child.path}` : child.path,
params: child.params ? { ...params, ...child.params } : params,
params: child.params
? { ...nonPathParams, ...child.params }
: nonPathParams,
};
}
return {
path: subPath,
params,
params: nonPathParams,
};
};
return { getActionForPathAndParams, getPathAndParamsForRoute };

View File

@@ -21,7 +21,47 @@ import withOrientation from '../withOrientation';
const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0;
const TITLE_OFFSET = Platform.OS === 'ios' ? 70 : 56;
// These can be adjusted by using headerTitleContainerStyle on navigationOptions
const TITLE_OFFSET_CENTER_ALIGN = Platform.OS === 'ios' ? 70 : 56;
const TITLE_OFFSET_LEFT_ALIGN = Platform.OS === 'ios' ? 20 : 56;
const getTitleOffsets = (
layoutPreset,
forceBackTitle,
hasLeftComponent,
hasRightComponent
) => {
if (layoutPreset === 'left') {
// Maybe at some point we should do something different if the back title is
// explicitly enabled, for now people can control it manually
let style = {
left: TITLE_OFFSET_LEFT_ALIGN,
right: TITLE_OFFSET_LEFT_ALIGN,
};
if (!hasLeftComponent) {
style.left = 0;
}
if (!hasRightComponent) {
style.right = 0;
}
return style;
} else if (layoutPreset === 'center') {
let style = {
left: TITLE_OFFSET_CENTER_ALIGN,
right: TITLE_OFFSET_CENTER_ALIGN,
};
if (!hasLeftComponent && !hasRightComponent) {
style.left = 0;
style.right = 0;
}
return style;
}
};
const getAppBarHeight = isLandscape => {
return Platform.OS === 'ios'
@@ -92,6 +132,7 @@ class Header extends React.PureComponent {
}
_renderTitleComponent = props => {
const { layoutPreset } = this.props;
const { options } = props.scene.descriptor;
const headerTitle = options.headerTitle;
if (React.isValidElement(headerTitle)) {
@@ -103,10 +144,10 @@ class Header extends React.PureComponent {
const color = options.headerTintColor;
const allowFontScaling = options.headerTitleAllowFontScaling;
// On iOS, width of left/right components depends on the calculated
// size of the title.
const onLayoutIOS =
Platform.OS === 'ios'
// When title is centered, the width of left/right components depends on the
// calculated size of the title.
const onLayout =
layoutPreset === 'center'
? e => {
this.setState({
widths: {
@@ -117,18 +158,24 @@ class Header extends React.PureComponent {
}
: undefined;
const RenderedHeaderTitle =
const HeaderTitleComponent =
headerTitle && typeof headerTitle !== 'string'
? headerTitle
: HeaderTitle;
return (
<RenderedHeaderTitle
onLayout={onLayoutIOS}
<HeaderTitleComponent
onLayout={onLayout}
allowFontScaling={allowFontScaling == null ? true : allowFontScaling}
style={[color ? { color } : null, titleStyle]}
style={[
color ? { color } : null,
layoutPreset === 'center'
? { textAlign: 'center' }
: { textAlign: 'left' },
titleStyle,
]}
>
{titleString}
</RenderedHeaderTitle>
</HeaderTitleComponent>
);
};
@@ -141,7 +188,7 @@ class Header extends React.PureComponent {
return options.headerLeft;
}
if (props.scene.index === 0) {
if (!options.headerLeft && props.scene.index === 0) {
return;
}
@@ -167,7 +214,9 @@ class Header extends React.PureComponent {
backImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
backTitleVisible={this.props.backTitleVisible}
titleStyle={options.headerBackTitleStyle}
layoutPreset={this.props.layoutPreset}
width={width}
/>
);
@@ -220,6 +269,11 @@ class Header extends React.PureComponent {
const { transitionPreset } = this.props;
let { style } = props;
if (options.headerLeftContainerStyle) {
style = [style, options.headerLeftContainerStyle];
}
// On Android, or if we have a custom header left, or if we have a custom back image, we
// do not use the modular header (which is the one that imitates UINavigationController)
if (
@@ -229,14 +283,14 @@ class Header extends React.PureComponent {
options.headerLeft === null
) {
return this._renderSubView(
props,
{ ...props, style },
'left',
this._renderLeftComponent,
this.props.leftInterpolator
);
} else {
return this._renderModularSubView(
props,
{ ...props, style },
'left',
this._renderModularLeftComponent,
this.props.leftLabelInterpolator,
@@ -246,24 +300,17 @@ class Header extends React.PureComponent {
}
_renderTitle(props, options) {
const style = {};
const { transitionPreset } = this.props;
if (Platform.OS === 'android') {
if (!options.hasLeftComponent) {
style.left = 0;
}
if (!options.hasRightComponent) {
style.right = 0;
}
} else if (
Platform.OS === 'ios' &&
!options.hasLeftComponent &&
!options.hasRightComponent
) {
style.left = 0;
style.right = 0;
}
const { layoutPreset, transitionPreset } = this.props;
let style = [
{ justifyContent: layoutPreset === 'center' ? 'center' : 'flex-start' },
getTitleOffsets(
layoutPreset,
false,
options.hasLeftComponent,
options.hasRightComponent
),
options.headerTitleContainerStyle,
];
return this._renderSubView(
{ ...props, style },
@@ -276,8 +323,15 @@ class Header extends React.PureComponent {
}
_renderRight(props) {
const { options } = props.scene.descriptor;
let { style } = props;
if (options.headerRightContainerStyle) {
style = [style, options.headerRightContainerStyle];
}
return this._renderSubView(
props,
{ ...props, style },
'right',
this._renderRightComponent,
this.props.rightInterpolator
@@ -371,7 +425,6 @@ class Header extends React.PureComponent {
styles[name],
props.style,
styleInterpolator({
// todo: determine if we really need to splat all this.props
...this.props,
...props,
}),
@@ -392,6 +445,7 @@ class Header extends React.PureComponent {
const title = this._renderTitle(props, {
hasLeftComponent: !!left,
hasRightComponent: !!right,
headerTitleContainerStyle: options.headerTitleContainerStyle,
});
const { isLandscape, transitionPreset } = this.props;
@@ -473,6 +527,18 @@ class Header extends React.PureComponent {
flexShrink,
flexBasis,
flexWrap,
position,
padding,
paddingHorizontal,
paddingRight,
paddingLeft,
// paddingVertical,
// paddingTop,
// paddingBottom,
top,
right,
bottom,
left,
...safeHeaderStyle
} = headerStyleObj;
@@ -485,6 +551,18 @@ class Header extends React.PureComponent {
warnIfHeaderStyleDefined(flexShrink, 'flexShrink');
warnIfHeaderStyleDefined(flexBasis, 'flexBasis');
warnIfHeaderStyleDefined(flexWrap, 'flexWrap');
warnIfHeaderStyleDefined(padding, 'padding');
warnIfHeaderStyleDefined(position, 'position');
warnIfHeaderStyleDefined(paddingHorizontal, 'paddingHorizontal');
warnIfHeaderStyleDefined(paddingRight, 'paddingRight');
warnIfHeaderStyleDefined(paddingLeft, 'paddingLeft');
// warnIfHeaderStyleDefined(paddingVertical, 'paddingVertical');
// warnIfHeaderStyleDefined(paddingTop, 'paddingTop');
// warnIfHeaderStyleDefined(paddingBottom, 'paddingBottom');
warnIfHeaderStyleDefined(top, 'top');
warnIfHeaderStyleDefined(right, 'right');
warnIfHeaderStyleDefined(bottom, 'bottom');
warnIfHeaderStyleDefined(left, 'left');
}
// TODO: warn if any unsafe styles are provided
@@ -503,7 +581,12 @@ class Header extends React.PureComponent {
<Animated.View
style={[
this.props.layoutInterpolator(this.props),
{ backgroundColor: DEFAULT_BACKGROUND_COLOR },
Platform.OS === 'ios' && !options.headerTransparent
? {
backgroundColor:
safeHeaderStyle.backgroundColor || DEFAULT_BACKGROUND_COLOR,
}
: null,
]}
>
<SafeAreaView forceInset={forceInset} style={containerStyles}>
@@ -518,7 +601,11 @@ class Header extends React.PureComponent {
}
function warnIfHeaderStyleDefined(value, styleProp) {
if (value !== undefined) {
if (styleProp === 'position' && value === 'absolute') {
console.warn(
"position: 'absolute' is not supported on headerStyle. If you would like to render content under the header, use the headerTransparent navigationOption."
);
} else if (value !== undefined) {
console.warn(
`${styleProp} was given a value of ${value}, this has no effect on headerStyle.`
);
@@ -556,6 +643,9 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
...platformContainerStyles,
borderBottomWidth: 0,
borderBottomColor: 'transparent',
elevation: 0,
},
header: {
...StyleSheet.absoluteFillObject,
@@ -587,12 +677,9 @@ const styles = StyleSheet.create({
title: {
bottom: 0,
top: 0,
left: TITLE_OFFSET,
right: TITLE_OFFSET,
position: 'absolute',
alignItems: 'center',
flexDirection: 'row',
justifyContent: Platform.OS === 'ios' ? 'center' : 'flex-start',
},
left: {
left: 0,

View File

@@ -62,9 +62,38 @@ class HeaderBackButton extends React.PureComponent {
}
render() {
const { onPress, pressColorAndroid, layoutPreset, title } = this.props;
let button = (
<TouchableItem
accessibilityComponentType="button"
accessibilityLabel={title}
accessibilityTraits="button"
testID="header-back"
delayPressIn={0}
onPress={onPress}
pressColor={pressColorAndroid}
style={styles.container}
borderless
>
<View style={styles.container}>
{this._renderBackImage()}
{this._maybeRenderTitle()}
</View>
</TouchableItem>
);
if (Platform.OS === 'android') {
return <View style={styles.androidButtonWrapper}>{button}</View>;
} else {
return button;
}
}
_maybeRenderTitle() {
const {
onPress,
pressColorAndroid,
layoutPreset,
backTitleVisible,
width,
title,
titleStyle,
@@ -79,41 +108,35 @@ class HeaderBackButton extends React.PureComponent {
const backButtonTitle = renderTruncated ? truncatedTitle : title;
// If the left preset is used and we aren't on Android, then we
// default to disabling the label
const titleDefaultsToDisabled =
layoutPreset === 'left' ||
Platform.OS === 'android' ||
typeof backButtonTitle !== 'string';
// If the title is explicitly enabled then we respect that
if (titleDefaultsToDisabled && !backTitleVisible) {
return null;
}
return (
<TouchableItem
accessibilityComponentType="button"
accessibilityLabel={backButtonTitle}
accessibilityTraits="button"
testID="header-back"
delayPressIn={0}
onPress={onPress}
pressColor={pressColorAndroid}
style={styles.container}
borderless
<Text
onLayout={this._onTextLayout}
style={[styles.title, !!tintColor && { color: tintColor }, titleStyle]}
numberOfLines={1}
>
<View style={styles.container}>
{this._renderBackImage()}
{Platform.OS === 'ios' &&
typeof backButtonTitle === 'string' && (
<Text
onLayout={this._onTextLayout}
style={[
styles.title,
!!tintColor && { color: tintColor },
titleStyle,
]}
numberOfLines={1}
>
{backButtonTitle}
</Text>
)}
</View>
</TouchableItem>
{backButtonTitle}
</Text>
);
}
}
const styles = StyleSheet.create({
androidButtonWrapper: {
margin: 13,
backgroundColor: 'transparent',
},
container: {
alignItems: 'center',
flexDirection: 'row',
@@ -137,7 +160,7 @@ const styles = StyleSheet.create({
: {
height: 24,
width: 24,
margin: 16,
margin: 3,
resizeMode: 'contain',
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }],
},

View File

@@ -17,7 +17,6 @@ const styles = StyleSheet.create({
fontSize: Platform.OS === 'ios' ? 17 : 20,
fontWeight: Platform.OS === 'ios' ? '700' : '500',
color: 'rgba(0, 0, 0, .9)',
textAlign: Platform.OS === 'ios' ? 'center' : 'left',
marginHorizontal: 16,
},
});

View File

@@ -130,16 +130,26 @@ export default function ScenesReducer(
return;
}
const lastScene = scenes.find(scene => scene.route.key === route.key);
const descriptor = lastScene && lastScene.descriptor;
staleScenes.set(key, {
index,
isActive: false,
isStale: true,
key,
route,
descriptor,
});
// We can get into a weird place where we have a queued transition and then clobber
// that transition without ever actually rendering the scene, in which case
// there is no lastScene. If the descriptor is not available on the lastScene
// or the descriptors prop then we just skip adding it to stale scenes and it's
// not ever rendered.
const descriptor = lastScene
? lastScene.descriptor
: descriptors[route.key];
if (descriptor) {
staleScenes.set(key, {
index,
isActive: false,
isStale: true,
key,
route,
descriptor,
});
}
});
}

View File

@@ -24,10 +24,14 @@ class StackView extends React.Component {
screenProps={this.props.screenProps}
navigation={this.props.navigation}
descriptors={this.props.descriptors}
onTransitionStart={this.props.onTransitionStart}
onTransitionStart={
this.props.onTransitionStart ||
this.props.navigationConfig.onTransitionStart
}
onTransitionEnd={(transition, lastTransition) => {
const { navigationConfig, navigation } = this.props;
const { onTransitionEnd } = navigationConfig;
const onTransitionEnd =
this.props.onTransitionEnd || navigationConfig.onTransitionEnd;
if (transition.navigation.state.isTransitioning) {
navigation.dispatch(
StackActions.completeTransition({

View File

@@ -34,6 +34,12 @@ const IS_IPHONE_X =
const EaseInOut = Easing.inOut(Easing.ease);
/**
* Enumerate possible values for validation
*/
const HEADER_LAYOUT_PRESET_VALUES = ['center', 'left'];
const HEADER_TRANSITION_PRESET_VALUES = ['uikit', 'fade-in-place'];
/**
* The max duration of the card animation in milliseconds after released gesture.
* The actual duration should be always less then that because the rest distance
@@ -159,6 +165,8 @@ class StackViewLayout extends React.Component {
scene,
mode: headerMode,
transitionPreset: this._getHeaderTransitionPreset(),
layoutPreset: this._getHeaderLayoutPreset(),
backTitleVisible: this._getheaderBackTitleVisible(),
leftInterpolator: headerLeftInterpolator,
titleInterpolator: headerTitleInterpolator,
rightInterpolator: headerRightInterpolator,
@@ -477,6 +485,40 @@ class StackViewLayout extends React.Component {
return 'float';
}
_getHeaderLayoutPreset() {
const { headerLayoutPreset } = this.props;
if (headerLayoutPreset) {
if (__DEV__) {
if (
this._getHeaderTransitionPreset() === 'uitkit' &&
headerLayoutPreset === 'left' &&
Platform.OS === 'ios'
) {
console.warn(
`headerTransitionPreset with the value 'ui-kit' is incompatible with headerLayoutPreset 'left'`
);
}
}
if (HEADER_LAYOUT_PRESET_VALUES.includes(headerLayoutPreset)) {
return headerLayoutPreset;
}
if (__DEV__) {
console.error(
`Invalid configuration applied for headerLayoutPreset - expected one of ${HEADER_LAYOUT_PRESET_VALUES.join(
', '
)} but received ${JSON.stringify(headerLayoutPreset)}`
);
}
}
if (Platform.OS === 'android') {
return 'left';
} else {
return 'center';
}
}
_getHeaderTransitionPreset() {
// On Android or with header mode screen, we always just use in-place,
// we ignore the option entirely (at least until we have other presets)
@@ -484,12 +526,28 @@ class StackViewLayout extends React.Component {
return 'fade-in-place';
}
// TODO: validations: 'fade-in-place' or 'uikit' are valid
if (this.props.headerTransitionPreset) {
return this.props.headerTransitionPreset;
} else {
return 'fade-in-place';
const { headerTransitionPreset } = this.props;
if (headerTransitionPreset) {
if (HEADER_TRANSITION_PRESET_VALUES.includes(headerTransitionPreset)) {
return headerTransitionPreset;
}
if (__DEV__) {
console.error(
`Invalid configuration applied for headerTransitionPreset - expected one of ${HEADER_TRANSITION_PRESET_VALUES.join(
', '
)} but received ${JSON.stringify(headerTransitionPreset)}`
);
}
}
return 'fade-in-place';
}
_getheaderBackTitleVisible() {
const { headerBackTitleVisible } = this.props;
return headerBackTitleVisible;
}
_renderInnerScene(scene) {

View File

@@ -166,7 +166,7 @@ class Transitioner extends React.Component {
render() {
return (
<View onLayout={this._onLayout} style={[styles.main]}>
<View onLayout={this._onLayout} style={styles.main}>
{this.props.render(this._transitionProps, this._prevTransitionProps)}
</View>
);

View File

@@ -1,9 +1,17 @@
import ScenesReducer from '../ScenesReducer';
const MOCK_DESCRIPTOR = {};
/**
* Simulate scenes transtion with changes of navigation states.
*/
function testTransition(states) {
let descriptors = states
.reduce((acc, state) => acc.concat(state), [])
.reduce((acc, key) => {
acc[key] = MOCK_DESCRIPTOR;
return acc;
}, {});
const routes = states.map(keys => ({
index: 0,
routes: keys.map(key => ({ key, routeName: '' })),
@@ -13,7 +21,7 @@ function testTransition(states) {
let scenes = [];
let prevState = null;
routes.forEach(nextState => {
scenes = ScenesReducer(scenes, nextState, prevState);
scenes = ScenesReducer(scenes, nextState, prevState, descriptors);
prevState = nextState;
});
@@ -29,6 +37,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: true,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_1',
route: {
key: '1',
@@ -39,6 +48,7 @@ describe('ScenesReducer', () => {
index: 1,
isActive: false,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_2',
route: {
key: '2',
@@ -57,6 +67,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: true,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_1',
route: {
key: '1',
@@ -67,6 +78,7 @@ describe('ScenesReducer', () => {
index: 1,
isActive: false,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_2',
route: {
key: '2',
@@ -77,6 +89,7 @@ describe('ScenesReducer', () => {
index: 2,
isActive: false,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_3',
route: {
key: '3',
@@ -136,8 +149,10 @@ describe('ScenesReducer', () => {
isTransitioning: false,
};
const scenes1 = ScenesReducer([], state1, null);
const scenes2 = ScenesReducer(scenes1, state2, state1);
const descriptors = { 1: jest.mock(), 2: jest.mock() };
const scenes1 = ScenesReducer([], state1, null, descriptors);
const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors);
expect(scenes1).not.toBe(scenes2);
});
@@ -160,8 +175,10 @@ describe('ScenesReducer', () => {
isTransitioning: false,
};
const scenes1 = ScenesReducer([], state1, null);
const scenes2 = ScenesReducer(scenes1, state2, state1);
const descriptors = { 1: MOCK_DESCRIPTOR, 2: MOCK_DESCRIPTOR };
const scenes1 = ScenesReducer([], state1, null, descriptors);
const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors);
expect(scenes1).not.toBe(scenes2);
});
@@ -184,8 +201,9 @@ describe('ScenesReducer', () => {
isTransitioning: false,
};
const scenes1 = ScenesReducer([], state1, null);
const scenes2 = ScenesReducer(scenes1, state2, state1);
const descriptors = { 1: MOCK_DESCRIPTOR, 2: MOCK_DESCRIPTOR };
const scenes1 = ScenesReducer([], state1, null, descriptors);
const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors);
expect(scenes1).not.toBe(scenes2);
});
@@ -198,6 +216,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: true,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_1',
route: {
key: '1',
@@ -208,6 +227,7 @@ describe('ScenesReducer', () => {
index: 1,
isActive: false,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_2',
route: {
key: '2',
@@ -218,6 +238,7 @@ describe('ScenesReducer', () => {
index: 2,
isActive: false,
isStale: true,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_3',
route: {
key: '3',
@@ -235,6 +256,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: false,
isStale: true,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_1',
route: {
key: '1',
@@ -245,6 +267,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: true,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_3',
route: {
key: '3',
@@ -255,6 +278,7 @@ describe('ScenesReducer', () => {
index: 1,
isActive: false,
isStale: true,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_2',
route: {
key: '2',
@@ -272,6 +296,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: false,
isStale: true,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_1',
route: {
key: '1',
@@ -282,6 +307,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: true,
isStale: false,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_2',
route: {
key: '2',
@@ -292,6 +318,7 @@ describe('ScenesReducer', () => {
index: 0,
isActive: false,
isStale: true,
descriptor: MOCK_DESCRIPTOR,
key: 'scene_3',
route: {
key: '3',