transitioner2

Introducing a wild and hacky prototype of Transitioner 2!

- "transitionProps" concept is now identical to transitioner.state
- Far simpler state management than before
- Descriptors only, no "scenes"
- No more "position"

Also, StackView is seeing improvements:
- Easier to understand when "transition props" are just this.props

- StackView only renders the current sceen and the one before it

Currently broken:
- Interpolation configuration, beyond the first push
- Attempting to move away from getSceneIndicesForInterpolationInputRange because it works with position and it is confusing as hell, but haven't worked around it yet
- Gestures, although some "backProgress" code is in the works
- Interpolation is super buggy
- onTransitionStart/End
- Header, although a lot of code is moved over
This commit is contained in:
Eric Vicenti
2018-03-11 00:11:58 -08:00
parent 71adb7cc4f
commit 0ea01673e5
16 changed files with 1886 additions and 165 deletions

View File

@@ -305,7 +305,8 @@ const AppNavigator = StackNavigator(
}
);
export default () => <AppNavigator />;
// export default () => <AppNavigator />;
export default SimpleStack;
const styles = StyleSheet.create({
item: {

View File

@@ -8,7 +8,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.get(state, 'a')).toEqual({
key: 'a',
@@ -21,7 +20,6 @@ describe('StateUtils', () => {
const state = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.indexOf(state, 'a')).toBe(0);
expect(NavigationStateUtils.indexOf(state, 'b')).toBe(1);
@@ -32,7 +30,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.has(state, 'b')).toBe(true);
expect(NavigationStateUtils.has(state, 'c')).toBe(false);
@@ -43,11 +40,9 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
isTransitioning: false,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
};
expect(NavigationStateUtils.push(state, { key: 'b', routeName })).toEqual(
@@ -59,7 +54,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }],
isTransitioning: false,
};
expect(() =>
NavigationStateUtils.push(state, { key: 'a', routeName })
@@ -71,12 +65,10 @@ describe('StateUtils', () => {
const state = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 0,
routes: [{ key: 'a', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.pop(state)).toEqual(newState);
});
@@ -85,7 +77,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.pop(state)).toBe(state);
});
@@ -95,12 +86,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.jumpToIndex(state, 0)).toBe(state);
expect(NavigationStateUtils.jumpToIndex(state, 1)).toEqual(newState);
@@ -110,7 +99,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(() => NavigationStateUtils.jumpToIndex(state, 2)).toThrow();
});
@@ -119,12 +107,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.jumpTo(state, 'a')).toBe(state);
expect(NavigationStateUtils.jumpTo(state, 'b')).toEqual(newState);
@@ -134,7 +120,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(() => NavigationStateUtils.jumpTo(state, 'c')).toThrow();
});
@@ -143,12 +128,10 @@ describe('StateUtils', () => {
const state = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.back(state)).toEqual(newState);
expect(NavigationStateUtils.back(newState)).toBe(newState);
@@ -158,12 +141,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(NavigationStateUtils.forward(state)).toEqual(newState);
expect(NavigationStateUtils.forward(newState)).toBe(newState);
@@ -174,12 +155,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'c', routeName }],
isTransitioning: false,
};
expect(
NavigationStateUtils.replaceAt(state, 'b', { key: 'c', routeName })
@@ -190,12 +169,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'a', routeName }, { key: 'c', routeName }],
isTransitioning: false,
};
expect(
NavigationStateUtils.replaceAtIndex(state, 1, { key: 'c', routeName })
@@ -206,7 +183,6 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
expect(
NavigationStateUtils.replaceAtIndex(state, 1, state.routes[1])
@@ -218,12 +194,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 1,
routes: [{ key: 'x', routeName }, { key: 'y', routeName }],
isTransitioning: false,
};
expect(
NavigationStateUtils.reset(state, [
@@ -241,12 +215,10 @@ describe('StateUtils', () => {
const state = {
index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
isTransitioning: false,
};
const newState = {
index: 0,
routes: [{ key: 'x', routeName }, { key: 'y', routeName }],
isTransitioning: false,
};
expect(
NavigationStateUtils.reset(

View File

@@ -84,7 +84,7 @@ export default function getChildEventSubscriber(addListener, key) {
action,
type: eventName,
};
const isTransitioning = !!state && state.isTransitioning;
const isTransitioning = !!state && !!state.transitioningFromKey;
const previouslyLastEmittedEvent = lastEmittedEvent;

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import createNavigationContainer from '../createNavigationContainer';
import createNavigator from './createNavigator';
import StackView from '../views/StackView/StackView';
import StackView from '../views/StackView/StackView2';
import StackRouter from '../routers/StackRouter';
function createStackNavigator(routeConfigMap, stackConfig = {}) {

View File

@@ -65,7 +65,7 @@ export default (routeConfigs, stackConfig = {}) => {
}
return {
key: 'StackRouterRoot',
isTransitioning: false,
transitioningFromKey: null,
index: 0,
routes: [
{
@@ -100,7 +100,7 @@ export default (routeConfigs, stackConfig = {}) => {
};
return {
key: 'StackRouterRoot',
isTransitioning: false,
transitioningFromKey: false,
index: 0,
routes: [route],
};
@@ -157,6 +157,7 @@ export default (routeConfigs, stackConfig = {}) => {
if (!state) {
return getInitialState(action);
}
const lastRouteKey = state.routes[state.index].key;
// Check if the focused child scene wants to handle the action, as long as
// it is not a reset to the root stack
@@ -221,13 +222,13 @@ export default (routeConfigs, stackConfig = {}) => {
},
};
}
// Return state with new index. Change isTransitioning only if index has changed
// Return state with new index. Change transitioningFromKey only if index has changed
return {
...state,
isTransitioning:
transitioningFromKey:
state.index !== lastRouteIndex
? action.immediate !== true
: undefined,
? action.immediate !== true ? lastRouteKey : null
: null,
index: lastRouteIndex,
routes,
};
@@ -253,7 +254,7 @@ export default (routeConfigs, stackConfig = {}) => {
}
return {
...StateUtils.push(state, route),
isTransitioning: action.immediate !== true,
transitioningFromKey: action.immediate !== true ? lastRouteKey : null,
};
} else if (
action.type === NavigationActions.PUSH &&
@@ -320,7 +321,7 @@ export default (routeConfigs, stackConfig = {}) => {
} else {
return {
...state,
isTransitioning: action.immediate !== true,
lastRouteKey: action.immediate !== true ? lastRouteKey : null,
index: 0,
routes: [state.routes[0]],
};
@@ -357,11 +358,11 @@ export default (routeConfigs, stackConfig = {}) => {
if (
action.type === NavigationActions.COMPLETE_TRANSITION &&
(action.key == null || action.key === state.key) &&
state.isTransitioning
state.transitioningFromKey
) {
return {
...state,
isTransitioning: false,
transitioningFromKey: null,
};
}
@@ -440,7 +441,7 @@ export default (routeConfigs, stackConfig = {}) => {
...state,
routes: state.routes.slice(0, backRouteIndex),
index: backRouteIndex - 1,
isTransitioning: immediate !== true,
transitioningFromKey: immediate !== true ? lastRouteKey : null,
};
} else if (
backRouteIndex === 0 &&

View File

@@ -67,7 +67,7 @@ export default (routeConfigs, config = {}) => {
state = {
routes,
index: initialRouteIndex,
isTransitioning: false,
transitioningFromKey: null,
};
// console.log(`${order.join('-')}: Initial state`, {state});
}

View File

@@ -18,7 +18,7 @@ describe('DrawerRouter', () => {
const state = router.getStateForAction(INIT_ACTION);
const expectedState = {
index: 0,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'Foo', routeName: 'Foo', params: undefined },
{ key: 'Bar', routeName: 'Bar', params: undefined },
@@ -32,7 +32,7 @@ describe('DrawerRouter', () => {
);
const expectedState2 = {
index: 1,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'Foo', routeName: 'Foo', params: undefined },
{ key: 'Bar', routeName: 'Bar', params: undefined },

View File

@@ -135,7 +135,7 @@ test('Handles deep action', () => {
const state1 = TestRouter.getStateForAction({ type: NavigationActions.INIT });
const expectedState = {
index: 0,
isTransitioning: false,
transitioningFromKey: false,
key: 'StackRouterRoot',
routes: [
{

View File

@@ -92,7 +92,7 @@ describe('StackRouter', () => {
expect(
router.getComponentForState({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'a', routeName: 'foo' },
{ key: 'b', routeName: 'bar' },
@@ -103,7 +103,7 @@ describe('StackRouter', () => {
expect(
router.getComponentForState({
index: 1,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'a', routeName: 'foo' },
{ key: 'b', routeName: 'bar' },
@@ -127,7 +127,7 @@ describe('StackRouter', () => {
expect(
router.getComponentForState({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'a', routeName: 'foo' },
{ key: 'b', routeName: 'bar' },
@@ -138,7 +138,7 @@ describe('StackRouter', () => {
expect(
router.getComponentForState({
index: 1,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'a', routeName: 'foo' },
{ key: 'b', routeName: 'bar' },
@@ -353,7 +353,7 @@ describe('StackRouter', () => {
const initState = TestRouter.getStateForAction(NavigationActions.init());
expect(initState).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [{ key: 'id-0', routeName: 'foo' }],
});
@@ -494,7 +494,7 @@ describe('StackRouter', () => {
const state = {
index: 2,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{ key: 'A', routeName: 'foo' },
{ key: 'B', routeName: 'bar', params: { bazId: '321' } },
@@ -507,7 +507,7 @@ describe('StackRouter', () => {
);
expect(poppedState.routes.length).toBe(1);
expect(poppedState.index).toBe(0);
expect(poppedState.isTransitioning).toBe(true);
expect(poppedState.transitioningFromKey).toBe('C');
const poppedState2 = TestRouter.getStateForAction(
NavigationActions.popToTop(),
poppedState
@@ -519,7 +519,7 @@ describe('StackRouter', () => {
);
expect(poppedImmediatelyState.routes.length).toBe(1);
expect(poppedImmediatelyState.index).toBe(0);
expect(poppedImmediatelyState.isTransitioning).toBe(false);
expect(poppedImmediatelyState.transitioningFromKey).toBe(null);
});
test('Navigate Pushes duplicate routeName', () => {
@@ -678,7 +678,7 @@ describe('StackRouter', () => {
const state = router.getStateForAction({ type: NavigationActions.INIT });
expect(state).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -706,7 +706,7 @@ describe('StackRouter', () => {
);
expect(state3).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -773,7 +773,7 @@ describe('StackRouter', () => {
state
);
expect(state2 && state2.index).toEqual(1);
expect(state2 && state2.isTransitioning).toEqual(true);
expect(state2 && state2.transitioningFromKey).toEqual(state.routes[0].key);
const state3 = router.getStateForAction(
{
type: NavigationActions.COMPLETE_TRANSITION,
@@ -781,7 +781,7 @@ describe('StackRouter', () => {
state2
);
expect(state3 && state3.index).toEqual(1);
expect(state3 && state3.isTransitioning).toEqual(false);
expect(state3 && state3.transitioningFromKey).toEqual(null);
});
test('Handle basic stack logic for components with router', () => {
@@ -803,7 +803,7 @@ describe('StackRouter', () => {
const state = router.getStateForAction({ type: NavigationActions.INIT });
expect(state).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -831,7 +831,7 @@ describe('StackRouter', () => {
);
expect(state3).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -905,7 +905,7 @@ describe('StackRouter', () => {
const state = router.getStateForAction({ type: NavigationActions.INIT });
expect(state).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -929,7 +929,7 @@ describe('StackRouter', () => {
const state = router.getStateForAction({ type: NavigationActions.INIT });
expect(state).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
@@ -1274,19 +1274,19 @@ describe('StackRouter', () => {
expect(state).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'id-2',
params: { code: 'test', foo: 'bar' },
routeName: 'main',
routes: [
{
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'id-1',
params: { code: 'test', foo: 'bar', id: '4' },
routeName: 'profile',
@@ -1333,19 +1333,19 @@ describe('StackRouter', () => {
expect(state2).toEqual({
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'StackRouterRoot',
routes: [
{
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'id-5',
params: { code: '', foo: 'bar' },
routeName: 'main',
routes: [
{
index: 0,
isTransitioning: false,
transitioningFromKey: null,
key: 'id-4',
params: { code: '', foo: 'bar', id: '4' },
routeName: 'profile',
@@ -1448,7 +1448,7 @@ describe('StackRouter', () => {
const state = {
index: 0,
isTransitioning: false,
transitioningFromKey: null,
routes: [
{
index: 1,
@@ -1664,10 +1664,12 @@ test('Handles deep navigate completion action', () => {
},
state
);
expect(state2 && state2.index).toEqual(0);
expect(state2 && state2.isTransitioning).toEqual(false);
expect(state2 && state2.routes[0].index).toEqual(1);
expect(state2 && state2.routes[0].isTransitioning).toEqual(true);
expect(state2.index).toEqual(0);
expect(state2.transitioningFromKey).toEqual(null);
expect(state2.routes[0].index).toEqual(1);
expect(state2.routes[0].transitioningFromKey).toEqual(
state.routes[0].routes[state.routes[0].index].key
);
expect(!!key).toEqual(true);
const state3 = router.getStateForAction(
{
@@ -1675,8 +1677,8 @@ test('Handles deep navigate completion action', () => {
},
state2
);
expect(state3 && state3.index).toEqual(0);
expect(state3 && state3.isTransitioning).toEqual(false);
expect(state3 && state3.routes[0].index).toEqual(1);
expect(state3 && state3.routes[0].isTransitioning).toEqual(false);
expect(state3.index).toEqual(0);
expect(state3.transitioningFromKey).toEqual(null);
expect(state3.routes[0].index).toEqual(1);
expect(state3.routes[0].transitioningFromKey).toEqual(null);
});

594
src/views/Header/Header2.js Normal file
View File

@@ -0,0 +1,594 @@
import React from 'react';
import {
Animated,
Dimensions,
Image,
Platform,
StyleSheet,
View,
ViewPropTypes,
} from 'react-native';
import { MaskedViewIOS } from '../../PlatformHelpers';
import SafeAreaView from 'react-native-safe-area-view';
import HeaderTitle from './HeaderTitle';
import HeaderBackButton from './HeaderBackButton';
import ModularHeaderBackButton from './ModularHeaderBackButton';
import HeaderStyleInterpolator from './HeaderStyleInterpolator2';
import withOrientation from '../withOrientation';
import { last } from 'rxjs/operators';
const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0;
const TITLE_OFFSET = Platform.OS === 'ios' ? 70 : 56;
const getAppBarHeight = isLandscape => {
return Platform.OS === 'ios'
? isLandscape && !Platform.isPad ? 32 : 44
: 56;
};
class Header extends React.PureComponent {
static defaultProps = {
leftInterpolator: HeaderStyleInterpolator.forLeft,
leftButtonInterpolator: HeaderStyleInterpolator.forLeftButton,
leftLabelInterpolator: HeaderStyleInterpolator.forLeftLabel,
titleFromLeftInterpolator: HeaderStyleInterpolator.forCenterFromLeft,
titleInterpolator: HeaderStyleInterpolator.forCenter,
rightInterpolator: HeaderStyleInterpolator.forRight,
};
static get HEIGHT() {
return APPBAR_HEIGHT + STATUSBAR_HEIGHT;
}
state = {
widths: {},
};
_getHeaderTitleString({ options }) {
if (typeof options.headerTitle === 'string') {
return options.headerTitle;
}
return options.title;
}
_getLastSceneDescriptor(descriptor) {
const { state } = this.props.navigation;
const index = state.routes.findIndex(r => r.key === descriptor.key);
if (index < 1) {
return null;
}
const lastKey = state.routes[index - 1].key;
return this.props.descriptors[lastKey];
}
_getBackButtonTitleString(descriptor) {
const lastSceneDescriptor = this._getLastSceneDescriptor(descriptor);
if (!lastSceneDescriptor) {
return null;
}
const { headerBackTitle } = lastSceneDescriptor.options;
if (headerBackTitle || headerBackTitle === null) {
return headerBackTitle;
}
return this._getHeaderTitleString(lastSceneDescriptor);
}
_getTruncatedBackButtonTitle(descriptor) {
const lastSceneDescriptor = this._getLastSceneDescriptor(descriptor);
if (!lastSceneDescriptor) {
return null;
}
return lastSceneDescriptor.options.headerTruncatedBackTitle;
}
_renderTitleComponent = props => {
const { options } = props.descriptor;
const headerTitle = options.headerTitle;
if (React.isValidElement(headerTitle)) {
return headerTitle;
}
const titleString = this._getHeaderTitleString(props.descriptor);
const titleStyle = options.headerTitleStyle;
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'
? e => {
this.setState({
widths: {
...this.state.widths,
[props.descriptor.key]: e.nativeEvent.layout.width,
},
});
}
: undefined;
const RenderedHeaderTitle =
headerTitle && typeof headerTitle !== 'string'
? headerTitle
: HeaderTitle;
return (
<RenderedHeaderTitle
onLayout={onLayoutIOS}
allowFontScaling={allowFontScaling == null ? true : allowFontScaling}
style={[color ? { color } : null, titleStyle]}
>
{titleString}
</RenderedHeaderTitle>
);
};
_renderLeftComponent = props => {
const { options } = props.descriptor;
if (
React.isValidElement(options.headerLeft) ||
options.headerLeft === null
) {
return options.headerLeft;
}
if (props.index === 0) {
return;
}
const backButtonTitle = this._getBackButtonTitleString(props.descriptor);
const truncatedBackButtonTitle = this._getTruncatedBackButtonTitle(
props.descriptor
);
const width = this.state.widths[props.descriptor.key]
? (this.props.layout.initWidth -
this.state.widths[props.descriptor.key]) /
2
: undefined;
const RenderedLeftComponent = options.headerLeft || HeaderBackButton;
const goBack = () => {
// Go back on next tick because button ripple effect needs to happen on Android
requestAnimationFrame(() => {
this.props.navigation.goBack(props.descriptor.key);
});
};
return (
<RenderedLeftComponent
onPress={goBack}
pressColorAndroid={options.headerPressColorAndroid}
tintColor={options.headerTintColor}
buttonImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
titleStyle={options.headerBackTitleStyle}
width={width}
/>
);
};
_renderModularLeftComponent = (
props,
ButtonContainerComponent,
LabelContainerComponent
) => {
const { options } = props.descriptor;
const backButtonTitle = this._getBackButtonTitleString(props.descriptor);
const truncatedBackButtonTitle = this._getTruncatedBackButtonTitle(
props.descriptor
);
const width = this.state.widths[props.descriptor.key]
? (this.props.layout.initWidth -
this.state.widths[props.descriptor.key]) /
2
: undefined;
return (
<ModularHeaderBackButton
onPress={this._navigateBack}
ButtonContainerComponent={ButtonContainerComponent}
LabelContainerComponent={LabelContainerComponent}
pressColorAndroid={options.headerPressColorAndroid}
tintColor={options.headerTintColor}
buttonImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
titleStyle={options.headerBackTitleStyle}
width={width}
/>
);
};
_renderRightComponent = props => {
const { headerRight } = props.descriptor.options;
return headerRight || null;
};
_renderLeft(props) {
const { options } = props.descriptor;
const { transitionPreset } = this.props;
// 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 (
transitionPreset !== 'uikit' ||
options.headerBackImage ||
options.headerLeft ||
options.headerLeft === null
) {
return this._renderSubView(
props,
'left',
this._renderLeftComponent,
this.props.leftInterpolator
);
} else {
return this._renderModularSubView(
props,
'left',
this._renderModularLeftComponent,
this.props.leftLabelInterpolator,
this.props.leftButtonInterpolator
);
}
}
_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;
}
return this._renderSubView(
{ ...props, style },
'title',
this._renderTitleComponent,
transitionPreset === 'uikit'
? this.props.titleFromLeftInterpolator
: this.props.titleInterpolator
);
}
_renderRight(props) {
return this._renderSubView(
props,
'right',
this._renderRightComponent,
this.props.rightInterpolator
);
}
_renderModularSubView(
props,
name,
renderer,
labelStyleInterpolator,
buttonStyleInterpolator
) {
const { descriptor, index, navigation } = props;
const { key } = descriptor;
// Never render a modular back button on the first screen in a stack.
if (index === 0) {
return;
}
const offset = navigation.state.index - index;
if (Math.abs(offset) > 2) {
// Scene is far away from the active scene. Hides it to avoid unnecessary
// rendering.
return null;
}
const ButtonContainer = ({ children }) => (
<Animated.View style={[buttonStyleInterpolator(props)]}>
{children}
</Animated.View>
);
const LabelContainer = ({ children }) => (
<Animated.View style={[labelStyleInterpolator(props)]}>
{children}
</Animated.View>
);
const subView = renderer(props, ButtonContainer, LabelContainer);
if (subView === null) {
return subView;
}
const isTransitioning = !!navigation.state.transitioningFromKey;
const pointerEvents = offset !== 0 || isTransitioning ? 'none' : 'box-none';
return (
<View
key={`${name}_${key}`}
pointerEvents={pointerEvents}
style={[styles.item, styles[name], props.style]}
>
{subView}
</View>
);
}
_renderSubView(props, name, renderer, styleInterpolator) {
const { descriptor, index, navigation } = props;
const { key } = descriptor;
const offset = navigation.state.index - index;
if (Math.abs(offset) > 2) {
// Scene is far away from the active scene. Hides it to avoid unnecessary
// rendering.
return null;
}
const subView = renderer(props);
if (subView == null) {
return null;
}
const isTransitioning = !!navigation.state.transitioningFromKey;
const pointerEvents = offset !== 0 || isTransitioning ? 'none' : 'box-none';
return (
<Animated.View
pointerEvents={pointerEvents}
key={`${name}_${key}`}
style={[
styles.item,
styles[name],
props.style,
styleInterpolator({
// todo: determine if we really need to splat all this.props
...this.props,
...props,
}),
]}
>
{subView}
</Animated.View>
);
}
_renderHeader(props) {
const left = this._renderLeft(props);
const right = this._renderRight(props);
const title = this._renderTitle(props, {
hasLeftComponent: !!left,
hasRightComponent: !!right,
});
const { isLandscape, transitionPreset } = this.props;
const { options } = props.descriptor;
const wrapperProps = {
style: styles.header,
key: `header_${props.descriptor.key}`,
};
if (
options.headerLeft ||
options.headerBackImage ||
Platform.OS !== 'ios' ||
transitionPreset !== 'uikit'
) {
return (
<View {...wrapperProps}>
{title}
{left}
{right}
</View>
);
} else {
return (
<MaskedViewIOS
{...wrapperProps}
maskElement={
<View style={styles.iconMaskContainer}>
<Image
source={require('../assets/back-icon-mask.png')}
style={styles.iconMask}
/>
<View style={styles.iconMaskFillerRect} />
</View>
}
>
{title}
{left}
{right}
</MaskedViewIOS>
);
}
}
render() {
let appBar;
const {
mode,
isLandscape,
navigation,
descriptors,
descriptor,
transition,
} = this.props;
const { index } = navigation.state;
if (mode === 'float') {
const scenesDescriptorsByIndex = [];
const { state } = navigation;
state.routes.forEach((route, routeIndex) => {
scenesDescriptorsByIndex[routeIndex] = descriptors[route.key];
});
const scenesProps = scenesDescriptorsByIndex.map(
(descriptor, descriptorIndex) => ({
...this.props,
descriptor,
index: descriptorIndex,
})
);
appBar = scenesProps.map(this._renderHeader, this);
} else {
appBar = this._renderHeader({ ...this.props, index });
}
const { options } = descriptor;
const { headerStyle = {} } = options;
const headerStyleObj = StyleSheet.flatten(headerStyle);
const appBarHeight = getAppBarHeight(isLandscape);
const {
alignItems,
justifyContent,
flex,
flexDirection,
flexGrow,
flexShrink,
flexBasis,
flexWrap,
...safeHeaderStyle
} = headerStyleObj;
if (__DEV__) {
warnIfHeaderStyleDefined(alignItems, 'alignItems');
warnIfHeaderStyleDefined(justifyContent, 'justifyContent');
warnIfHeaderStyleDefined(flex, 'flex');
warnIfHeaderStyleDefined(flexDirection, 'flexDirection');
warnIfHeaderStyleDefined(flexGrow, 'flexGrow');
warnIfHeaderStyleDefined(flexShrink, 'flexShrink');
warnIfHeaderStyleDefined(flexBasis, 'flexBasis');
warnIfHeaderStyleDefined(flexWrap, 'flexWrap');
}
// TODO: warn if any unsafe styles are provided
const containerStyles = [
options.headerTransparent
? styles.transparentContainer
: styles.container,
{ height: appBarHeight },
safeHeaderStyle,
];
const { headerForceInset } = options;
const forceInset = headerForceInset || { top: 'always', bottom: 'never' };
return (
<SafeAreaView forceInset={forceInset} style={containerStyles}>
<View style={StyleSheet.absoluteFill}>{options.headerBackground}</View>
<View style={{ flex: 1 }}>{appBar}</View>
</SafeAreaView>
);
}
}
function warnIfHeaderStyleDefined(value, styleProp) {
if (value !== undefined) {
console.warn(
`${styleProp} was given a value of ${value}, this has no effect on headerStyle.`
);
}
}
let platformContainerStyles;
if (Platform.OS === 'ios') {
platformContainerStyles = {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#A7A7AA',
};
} else {
platformContainerStyles = {
shadowColor: 'black',
shadowOpacity: 0.1,
shadowRadius: StyleSheet.hairlineWidth,
shadowOffset: {
height: StyleSheet.hairlineWidth,
},
elevation: 4,
};
}
const styles = StyleSheet.create({
container: {
backgroundColor: Platform.OS === 'ios' ? '#F7F7F7' : '#FFF',
...platformContainerStyles,
},
transparentContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
...platformContainerStyles,
},
header: {
...StyleSheet.absoluteFillObject,
flexDirection: 'row',
},
item: {
backgroundColor: 'transparent',
},
iconMaskContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
},
iconMaskFillerRect: {
flex: 1,
backgroundColor: '#d8d8d8',
marginLeft: -3,
},
iconMask: {
// These are mostly the same as the icon in ModularHeaderBackButton
height: 21,
width: 12,
marginLeft: 9,
marginTop: -0.5, // resizes down to 20.5
alignSelf: 'center',
resizeMode: 'contain',
},
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,
bottom: 0,
top: 0,
position: 'absolute',
alignItems: 'center',
flexDirection: 'row',
},
right: {
right: 0,
bottom: 0,
top: 0,
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
},
});
export default withOrientation(Header);

View File

@@ -0,0 +1,176 @@
import { Dimensions, I18nManager } from 'react-native';
const crossFadeInterpolation = (first, index, last) => ({
inputRange: [first, index - 0.9, index - 0.2, index, last],
outputRange: [0, 0, 0.3, 1, 0],
});
/**
* Utility that builds the style for the navigation header.
*
* +-------------+-------------+-------------+
* | | | |
* | Left | Title | Right |
* | Component | Component | Component |
* | | | |
* +-------------+-------------+-------------+
*/
function forLeft(props) {
const { position, descriptor, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
};
}
function forCenter(props) {
const { position, scene } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
};
}
function forRight(props) {
const { position, scene } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
};
}
/**
* iOS UINavigationController style interpolators
*/
function forLeftButton(props) {
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
return {
opacity: position.interpolate({
inputRange: [
first,
first + Math.abs(index - first) / 2,
index,
last - Math.abs(last - index) / 2,
last,
],
outputRange: [0, 0.5, 1, 0.5, 0],
}),
};
}
/*
* NOTE: this offset calculation is a an approximation that gives us
* decent results in many cases, but it is ultimately a poor substitute
* for text measurement. See the comment on title for more information.
*
* - 70 is the width of the left button area.
* - 25 is the width of the left button icon (to account for label offset)
*/
const LEFT_LABEL_OFFSET = Dimensions.get('window').width / 2 - 70 - 25;
function forLeftLabel(props) {
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
const offset = LEFT_LABEL_OFFSET;
return {
// For now we fade out the label before fading in the title, so the
// differences between the label and title position can be hopefully not so
// noticable to the user
opacity: position.interpolate({
inputRange: [first, index - 0.35, index, index + 0.5, last],
outputRange: [0, 0, 1, 0.5, 0],
}),
transform: [
{
translateX: position.interpolate({
inputRange: [first, index, last],
outputRange: I18nManager.isRTL
? [-offset, 0, offset]
: [offset, 0, -offset * 1.5],
}),
},
],
};
}
/*
* NOTE: this offset calculation is a an approximation that gives us
* decent results in many cases, but it is ultimately a poor substitute
* for text measurement. We want the back button label to transition
* smoothly into the title text and to do this we need to understand
* where the title is positioned within the title container (since it is
* centered).
*
* - 70 is the width of the left button area.
* - 25 is the width of the left button icon (to account for label offset)
*/
const TITLE_OFFSET_IOS = Dimensions.get('window').width / 2 - 70 + 25;
function forCenterFromLeft(props) {
const { position, scene } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
const inputRange = [first, index - 0.5, index, index + 0.5, last];
const offset = TITLE_OFFSET_IOS;
return {
opacity: position.interpolate({
inputRange: [first, index - 0.5, index, index + 0.7, last],
outputRange: [0, 0, 1, 0, 0],
}),
transform: [
{
translateX: position.interpolate({
inputRange: [first, index, last],
outputRange: I18nManager.isRTL
? [-offset, 0, offset]
: [offset, 0, -offset],
}),
},
],
};
}
export default {
forLeft,
forLeftButton,
forLeftLabel,
forCenterFromLeft,
forCenter,
forRight,
};

View File

@@ -0,0 +1,546 @@
import * as React from 'react';
import Transitioner from './Transitioner2';
import NavigationActions from '../../NavigationActions';
import Transitions from './StackViewTransitions';
const NativeAnimatedModule =
NativeModules && NativeModules.NativeAnimatedModule;
import clamp from 'clamp';
import {
Animated,
StyleSheet,
PanResponder,
Platform,
View,
I18nManager,
Easing,
NativeModules,
} from 'react-native';
import Card from './StackViewCard';
// import Header from '../Header/Header2'; // WIP.. interpolation reconfiguration, fun!
import SceneView from '../SceneView';
import invariant from '../../utils/invariant';
import * as ReactNativeFeatures from '../../utils/ReactNativeFeatures';
const emptyFunction = () => {};
const EaseInOut = Easing.inOut(Easing.ease);
/**
* 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
* is always less then the full distance of the layout.
*/
const ANIMATION_DURATION = 500;
/**
* The gesture distance threshold to trigger the back behavior. For instance,
* `1/2` means that moving greater than 1/2 of the width of the screen will
* trigger a back action
*/
const POSITION_THRESHOLD = 1 / 2;
/**
* The threshold (in pixels) to start the gesture action.
*/
const RESPOND_THRESHOLD = 20;
/**
* The distance of touch start from the edge of the screen where the gesture will be recognized
*/
const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 25;
const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135;
const animatedSubscribeValue = animatedValue => {
if (!animatedValue.__isNative) {
return;
}
if (Object.keys(animatedValue._listeners).length === 0) {
animatedValue.addListener(emptyFunction);
}
};
class StackViewLayout extends React.Component {
/**
* Used to identify the starting point of the position when the gesture starts, such that it can
* be updated according to its relative position. This means that a card can effectively be
* "caught"- If a gesture starts while a card is animating, the card does not jump into a
* corresponding location for the touch.
*/
_gestureStartValue = 0;
// tracks if a touch is currently happening
_isResponding = false;
/**
* immediateIndex is used to represent the expected index that we will be on after a
* transition. To achieve a smooth animation when swiping back, the action to go back
* doesn't actually fire until the transition completes. The immediateIndex is used during
* the transition so that gestures can be handled correctly. This is a work-around for
* cases when the user quickly swipes back several times.
*/
_immediateIndex = null;
// _panResponder = PanResponder.create({
// onPanResponderTerminate: () => {
// this._isResponding = false;
// this._reset(index, 0);
// },
// onPanResponderGrant: () => {
// position.stopAnimation((value: number) => {
// this._isResponding = true;
// this._gestureStartValue = value;
// });
// },
// onMoveShouldSetPanResponder: (event, gesture) => {
// if (index !== scene.index) {
// return false;
// }
// const immediateIndex =
// this._immediateIndex == null ? index : this._immediateIndex;
// const currentDragDistance = gesture[isVertical ? 'dy' : 'dx'];
// const currentDragPosition =
// event.nativeEvent[isVertical ? 'pageY' : 'pageX'];
// const axisLength = isVertical
// ? layout.height.__getValue()
// : layout.width.__getValue();
// const axisHasBeenMeasured = !!axisLength;
// // Measure the distance from the touch to the edge of the screen
// const screenEdgeDistance = gestureDirectionInverted
// ? axisLength - (currentDragPosition - currentDragDistance)
// : currentDragPosition - currentDragDistance;
// // Compare to the gesture distance relavant to card or modal
// const { options } = scene.descriptor;
// const {
// gestureResponseDistance: userGestureResponseDistance = {},
// } = options;
// const gestureResponseDistance = isVertical
// ? userGestureResponseDistance.vertical ||
// GESTURE_RESPONSE_DISTANCE_VERTICAL
// : userGestureResponseDistance.horizontal ||
// GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
// // GESTURE_RESPONSE_DISTANCE is about 25 or 30. Or 135 for modals
// if (screenEdgeDistance > gestureResponseDistance) {
// // Reject touches that started in the middle of the screen
// return false;
// }
// const hasDraggedEnough =
// Math.abs(currentDragDistance) > RESPOND_THRESHOLD;
// const isOnFirstCard = immediateIndex === 0;
// const shouldSetResponder =
// hasDraggedEnough && axisHasBeenMeasured && !isOnFirstCard;
// return shouldSetResponder;
// },
// onPanResponderMove: (event, gesture) => {
// // Handle the moving touches for our granted responder
// const startValue = this._gestureStartValue;
// const axis = isVertical ? 'dy' : 'dx';
// const axisDistance = isVertical
// ? layout.height.__getValue()
// : layout.width.__getValue();
// const currentValue =
// (I18nManager.isRTL && axis === 'dx') !== gestureDirectionInverted
// ? startValue + gesture[axis] / axisDistance
// : startValue - gesture[axis] / axisDistance;
// const value = clamp(index - 1, currentValue, index);
// position.setValue(value);
// },
// onPanResponderTerminationRequest: () =>
// // Returning false will prevent other views from becoming responder while
// // the navigation view is the responder (mid-gesture)
// false,
// onPanResponderRelease: (event, gesture) => {
// if (!this._isResponding) {
// return;
// }
// this._isResponding = false;
// const immediateIndex =
// this._immediateIndex == null ? index : this._immediateIndex;
// // Calculate animate duration according to gesture speed and moved distance
// const axisDistance = isVertical
// ? layout.height.__getValue()
// : layout.width.__getValue();
// const movementDirection = gestureDirectionInverted ? -1 : 1;
// const movedDistance =
// movementDirection * gesture[isVertical ? 'dy' : 'dx'];
// const gestureVelocity =
// movementDirection * gesture[isVertical ? 'vy' : 'vx'];
// const defaultVelocity = axisDistance / ANIMATION_DURATION;
// const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity);
// const resetDuration = gestureDirectionInverted
// ? (axisDistance - movedDistance) / velocity
// : movedDistance / velocity;
// const goBackDuration = gestureDirectionInverted
// ? movedDistance / velocity
// : (axisDistance - movedDistance) / velocity;
// // To asyncronously get the current animated value, we need to run stopAnimation:
// position.stopAnimation(value => {
// // If the speed of the gesture release is significant, use that as the indication
// // of intent
// if (gestureVelocity < -0.5) {
// this._reset(immediateIndex, resetDuration);
// return;
// }
// if (gestureVelocity > 0.5) {
// this._goBack(immediateIndex, goBackDuration);
// return;
// }
// // Then filter based on the distance the screen was moved. Over a third of the way swiped,
// // and the back will happen.
// if (value <= index - POSITION_THRESHOLD) {
// this._goBack(immediateIndex, goBackDuration);
// } else {
// this._reset(immediateIndex, resetDuration);
// }
// });
// },
// });
_renderHeader(descriptor, headerMode) {
const { options } = descriptor;
const { header } = options;
if (typeof header !== 'undefined' && typeof header !== 'function') {
return header;
}
// const renderHeader = header || (props => <Header {...props} />);
const renderHeader = header || (props => null);
const {
headerLeftInterpolator,
headerTitleInterpolator,
headerRightInterpolator,
} = this._getTransitionConfig();
const {
mode,
transitionProps,
prevTransitionProps,
...passProps
} = this.props;
return renderHeader({
...passProps,
...transitionProps,
descriptor,
mode: headerMode,
transitionPreset: this._getHeaderTransitionPreset(),
leftInterpolator: headerLeftInterpolator,
titleInterpolator: headerTitleInterpolator,
rightInterpolator: headerRightInterpolator,
});
}
// eslint-disable-next-line class-methods-use-this
_animatedSubscribe(props) {
// Hack to make this work with native driven animations. We add a single listener
// so the JS value of the following animated values gets updated. We rely on
// some Animated private APIs and not doing so would require using a bunch of
// value listeners but we'd have to remove them to not leak and I'm not sure
// when we'd do that with the current structure we have. `stopAnimation` callback
// is also broken with native animated values that have no listeners so if we
// want to remove this we have to fix this too.
animatedSubscribeValue(props.layout.width);
animatedSubscribeValue(props.layout.height);
animatedSubscribeValue(props.position);
}
// _reset(resetToIndex, duration) {
// if (
// Platform.OS === 'ios' &&
// ReactNativeFeatures.supportsImprovedSpringAnimation()
// ) {
// Animated.spring(this.props.transitionProps.position, {
// toValue: resetToIndex,
// stiffness: 5000,
// damping: 600,
// mass: 3,
// useNativeDriver: this.props.transitionProps.position.__isNative,
// }).start();
// } else {
// Animated.timing(this.props.transitionProps.position, {
// toValue: resetToIndex,
// duration,
// easing: EaseInOut,
// useNativeDriver: this.props.transitionProps.position.__isNative,
// }).start();
// }
// }
// _goBack(backFromIndex, duration) {
// const { navigation, position, scenes } = this.props.transitionProps;
// const toValue = Math.max(backFromIndex - 1, 0);
// // set temporary index for gesture handler to respect until the action is
// // dispatched at the end of the transition.
// this._immediateIndex = toValue;
// const onCompleteAnimation = () => {
// this._immediateIndex = null;
// const backFromScene = scenes.find(s => s.index === toValue + 1);
// if (!this._isResponding && backFromScene) {
// navigation.dispatch(
// NavigationActions.back({
// key: backFromScene.route.key,
// immediate: true,
// })
// );
// }
// };
// if (
// Platform.OS === 'ios' &&
// ReactNativeFeatures.supportsImprovedSpringAnimation()
// ) {
// Animated.spring(position, {
// toValue,
// stiffness: 5000,
// damping: 600,
// mass: 3,
// useNativeDriver: position.__isNative,
// }).start(onCompleteAnimation);
// } else {
// Animated.timing(position, {
// toValue,
// duration,
// easing: EaseInOut,
// useNativeDriver: position.__isNative,
// }).start(onCompleteAnimation);
// }
// }
render() {
let floatingHeader = null;
const headerMode = this._getHeaderMode();
const {
navigation,
transition,
descriptor,
descriptors,
layout,
mode,
} = this.props;
if (headerMode === 'float') {
floatingHeader = this._renderHeader(descriptor, headerMode);
}
const { index, routes } = navigation.state;
const isVertical = mode === 'modal';
const { options } = descriptor;
const gestureDirectionInverted = options.gestureDirection === 'inverted';
const gesturesEnabled =
typeof options.gesturesEnabled === 'boolean'
? options.gesturesEnabled
: Platform.OS === 'ios';
// const handlers = gesturesEnabled ? this._panResponder.panHandlers : {};
const handlers = {};
const containerStyle = [
styles.container,
this._getTransitionConfig().containerStyle,
];
let forwardScene = null;
let backwardScene = null;
if (transition) {
const { fromDescriptor, toDescriptor } = transition;
const fromKey = fromDescriptor.key;
const toKey = toDescriptor.key;
const toIndex = navigation.state.routes.findIndex(r => r.key === toKey);
invariant(
toIndex !== -1,
`Could not find toIndex in navigation state for ${fromKey}`
);
const fromIndex = navigation.state.routes.findIndex(
r => r.key === fromKey
);
if (fromIndex == -1) {
// we are coming from a screen that is no longer in the stack
backwardScene = fromDescriptor;
} else if (toIndex > fromIndex) {
// presumably we are going doing a push.
backwardScene = fromDescriptor;
} else {
// we are navigating back, and the forward scene is on top
forwardScene = fromDescriptor;
}
} else if (index > 0) {
// when we aren't transitioning, render the previous screen in case we swipe back.
const previousKey = routes[index - 1].key;
const previousDescriptor = descriptors[previousKey];
backwardScene = previousDescriptor;
}
return (
<View {...handlers} style={containerStyle}>
<View style={styles.scenes}>
{backwardScene && this._renderScene(backwardScene, index - 1)}
{this._renderScene(descriptor, index)}
{forwardScene && this._renderScene(forwardScene, index + 1)}
</View>
{floatingHeader}
</View>
);
}
_getHeaderMode() {
if (this.props.headerMode) {
return this.props.headerMode;
}
if (Platform.OS === 'android' || this.props.mode === 'modal') {
return 'screen';
}
return 'float';
}
_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)
if (Platform.OS === 'android' || this._getHeaderMode() === 'screen') {
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';
}
}
_renderInnerScene(descriptor) {
const { options, navigation, getComponent } = descriptor;
const SceneComponent = getComponent();
const { screenProps } = this.props;
const headerMode = this._getHeaderMode();
if (headerMode === 'screen') {
return (
<View style={styles.container}>
<View style={{ flex: 1 }}>
<SceneView
screenProps={screenProps}
navigation={navigation}
component={SceneComponent}
/>
</View>
{this._renderHeader(descriptor, headerMode)}
</View>
);
}
return (
<SceneView
screenProps={screenProps}
navigation={navigation}
component={SceneComponent}
/>
);
}
_getTransitionConfig = () => {
const isModal = this.props.mode === 'modal';
return Transitions.getTransitionConfig(
this.props.transitionConfig,
this.props,
isModal
);
};
_renderScene = (descriptor, index) => {
const { screenInterpolator } = this._getTransitionConfig();
const style =
screenInterpolator &&
screenInterpolator({ ...this.props, descriptor, index });
return (
<Card
{...this.props}
// providing this descriptor will override this.props.descriptor, to tell the card exactly which scene to render, instead of this.props.descriptor, which defines what scene is active
descriptor={descriptor}
index={index}
key={`card_${descriptor.key}`}
style={[style, this.props.cardStyle]}
>
{this._renderInnerScene(descriptor)}
</Card>
);
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
// Header is physically rendered after scenes so that Header won't be
// covered by the shadows of the scenes.
// That said, we'd have use `flexDirection: 'column-reverse'` to move
// Header above the scenes.
flexDirection: 'column-reverse',
},
scenes: {
flex: 1,
},
});
class StackView extends React.Component {
static defaultProps = {
navigationConfig: {
mode: 'card',
},
};
render() {
return (
<Transitioner
render={this._render}
configureTransition={this._configureTransition}
navigation={this.props.navigation}
descriptors={this.props.descriptors}
// onTransitionStart={this.props.onTransitionStart}
// onTransitionEnd={(lastTransition, transition) => {
// const { onTransitionEnd, navigation } = this.props;
// navigation.dispatch(
// NavigationActions.completeTransition({
// key: navigation.state.key,
// })
// );
// onTransitionEnd && onTransitionEnd(lastTransition, transition);
// }}
/>
);
}
_configureTransition = transitionProps => {
return {
...Transitions.getTransitionConfig(
this.props.navigationConfig.transitionConfig,
transitionProps,
this.props.navigationConfig.mode === 'modal'
).transitionSpec,
useNativeDriver: !!NativeAnimatedModule,
};
};
_render = transitionProps => {
const { screenProps } = this.props;
return <StackViewLayout {...transitionProps} screenProps={screenProps} />;
};
}
export default StackView;

View File

@@ -60,21 +60,19 @@ const FadeOutToBottomAndroid = {
screenInterpolator: StyleInterpolator.forFadeFromBottomAndroid,
};
function defaultTransitionConfig(
transitionProps,
prevTransitionProps,
isModal
) {
function defaultTransitionConfig(transitionProps, isModal) {
if (Platform.OS === 'android') {
// Use the default Android animation no matter if the screen is a modal.
// Android doesn't have full-screen modals like iOS does, it has dialogs.
if (
prevTransitionProps &&
transitionProps.index < prevTransitionProps.index
) {
// Navigating back to the previous screen
return FadeOutToBottomAndroid;
}
// todo, uncomment and fix, stop using prevTransitionProps
// // Use the default Android animation no matter if the screen is a modal.
// // Android doesn't have full-screen modals like iOS does, it has dialogs.
// if (
// prevTransitionProps &&
// transitionProps.index < prevTransitionProps.index
// ) {
// // Navigating back to the previous screen
// return FadeOutToBottomAndroid;
// }
return FadeInFromBottomAndroid;
}
// iOS and other platforms
@@ -84,21 +82,12 @@ function defaultTransitionConfig(
return SlideFromRightIOS;
}
function getTransitionConfig(
transitionConfigurer,
transitionProps,
prevTransitionProps,
isModal
) {
const defaultConfig = defaultTransitionConfig(
transitionProps,
prevTransitionProps,
isModal
);
function getTransitionConfig(transitionConfigurer, transitionProps, isModal) {
const defaultConfig = defaultTransitionConfig(transitionProps, isModal);
if (transitionConfigurer) {
return {
...defaultConfig,
...transitionConfigurer(transitionProps, prevTransitionProps, isModal),
...transitionConfigurer(transitionProps, isModal),
};
}
return defaultConfig;

View File

@@ -0,0 +1,268 @@
import { Animated, Easing, Platform } from 'react-native';
import * as ReactNativeFeatures from '../../utils/ReactNativeFeatures';
import { I18nManager } from 'react-native';
import getSceneIndicesForInterpolationInputRange from '../../utils/getSceneIndicesForInterpolationInputRange';
/**
* Utility that builds the style for the card in the cards stack.
*
* +------------+
* +-+ |
* +-+ | |
* | | | |
* | | | Focused |
* | | | Card |
* | | | |
* +-+ | |
* +-+ |
* +------------+
*/
/**
* Render the initial style when the initial layout isn't measured yet.
*/
function forInitial(props) {
const { navigation, descriptor } = props;
const { state } = navigation;
const activeKey = state.routes[state.index].key;
const focused = descriptor.key === activeKey;
const opacity = focused ? 1 : 0;
// If not focused, move the card far away.
const translate = focused ? 0 : 1000000;
return {
opacity,
transform: [{ translateX: translate }, { translateY: translate }],
};
}
/**
* Standard iOS-style slide in from the right.
*/
function forHorizontal(props) {
const { layout, transition, navigation, index } = props;
const { state } = navigation;
if (!layout.isMeasured) {
return forInitial(props);
}
const first = index - 1;
const last = index + 1;
const opacity = transition
? transition.progress.interpolate({
inputRange: [first, first + 0.01, index, last - 0.01, last],
outputRange: [0, 1, 1, 0.85, 0],
})
: 1;
const width = layout.initWidth;
const translateX = transition
? transition.progress.interpolate({
inputRange: [first, index, last],
outputRange: I18nManager.isRTL
? [-width, 0, width * 0.3]
: [width, 0, width * -0.3],
})
: 0;
return {
opacity,
transform: [{ translateX }],
};
}
/**
* Standard iOS-style slide in from the bottom (used for modals).
*/
function forVertical(props) {
const { layout, transition, descriptor } = props;
if (!layout.isMeasured) {
return forInitial(props);
}
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
const opacity = transition.progress.interpolate({
inputRange: [first, first + 0.01, index, last - 0.01, last],
outputRange: [0, 1, 1, 0.85, 0],
});
const height = layout.initHeight;
const translateY = transition.progress.interpolate({
inputRange: [first, index, last],
outputRange: [height, 0, 0],
});
const translateX = 0;
return {
opacity,
transform: [{ translateX }, { translateY }],
};
}
/**
* Standard Android-style fade in from the bottom.
*/
function forFadeFromBottomAndroid(props) {
const { layout, position, scene } = props;
if (!layout.isMeasured) {
return forInitial(props);
}
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
const inputRange = [first, index, last - 0.01, last];
const opacity = position.interpolate({
inputRange,
outputRange: [0, 1, 1, 0],
});
const translateY = position.interpolate({
inputRange,
outputRange: [50, 0, 0, 0],
});
const translateX = 0;
return {
opacity,
transform: [{ translateX }, { translateY }],
};
}
/**
* fadeIn and fadeOut
*/
function forFade(props) {
const { layout, position, scene } = props;
if (!layout.isMeasured) {
return forInitial(props);
}
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
const { first, last } = interpolate;
const index = scene.index;
const opacity = position.interpolate({
inputRange: [first, index, last],
outputRange: [0, 1, 1],
});
return {
opacity,
};
}
const StyleInterpolator = {
forHorizontal,
forVertical,
forFadeFromBottomAndroid,
forFade,
};
let IOSTransitionSpec;
if (ReactNativeFeatures.supportsImprovedSpringAnimation()) {
// These are the exact values from UINavigationController's animation configuration
IOSTransitionSpec = {
timing: Animated.spring,
stiffness: 1000,
damping: 500,
mass: 3,
};
} else {
// This is an approximation of the IOS spring animation using a derived bezier curve
IOSTransitionSpec = {
duration: 500,
easing: Easing.bezier(0.2833, 0.99, 0.31833, 0.99),
timing: Animated.timing,
};
}
// Standard iOS navigation transition
const SlideFromRightIOS = {
transitionSpec: IOSTransitionSpec,
screenInterpolator: StyleInterpolator.forHorizontal,
containerStyle: {
backgroundColor: '#000',
},
};
// Standard iOS navigation transition for modals
const ModalSlideFromBottomIOS = {
transitionSpec: IOSTransitionSpec,
screenInterpolator: StyleInterpolator.forVertical,
containerStyle: {
backgroundColor: '#000',
},
};
// Standard Android navigation transition when opening an Activity
const FadeInFromBottomAndroid = {
// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_open_enter.xml
transitionSpec: {
duration: 350,
easing: Easing.out(Easing.poly(5)), // decelerate
timing: Animated.timing,
},
screenInterpolator: StyleInterpolator.forFadeFromBottomAndroid,
};
// Standard Android navigation transition when closing an Activity
const FadeOutToBottomAndroid = {
// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_close_exit.xml
transitionSpec: {
duration: 230,
easing: Easing.in(Easing.poly(4)), // accelerate
timing: Animated.timing,
},
screenInterpolator: StyleInterpolator.forFadeFromBottomAndroid,
};
function defaultTransitionConfig(transitionProps, isModal) {
if (Platform.OS === 'android') {
// todo, uncomment and fix, stop using prevTransitionProps
// // Use the default Android animation no matter if the screen is a modal.
// // Android doesn't have full-screen modals like iOS does, it has dialogs.
// if (
// prevTransitionProps &&
// transitionProps.index < prevTransitionProps.index
// ) {
// // Navigating back to the previous screen
// return FadeOutToBottomAndroid;
// }
return FadeInFromBottomAndroid;
}
// iOS and other platforms
if (isModal) {
return ModalSlideFromBottomIOS;
}
return SlideFromRightIOS;
}
function getTransitionConfig(transitionConfigurer, transitionProps, isModal) {
const defaultConfig = defaultTransitionConfig(transitionProps, isModal);
if (transitionConfigurer) {
return {
...defaultConfig,
...transitionConfigurer(transitionProps, isModal),
};
}
return defaultConfig;
}
export default {
defaultTransitionConfig,
getTransitionConfig,
StyleInterpolator,
};

View File

@@ -0,0 +1,225 @@
import React from 'react';
import { Animated, Easing, StyleSheet, View } from 'react-native';
import invariant from '../../utils/invariant';
// Used for all animations unless overriden
const DefaultTransitionSpec = {
duration: 250,
easing: Easing.inOut(Easing.ease),
timing: Animated.timing,
};
class Transitioner extends React.Component {
_isMounted = false;
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
static getDerivedStateFromProps = (props, lastState) => {
const { navigation, descriptors } = props;
const { state } = navigation;
const canGoBack = state.index > 0;
const activeKey = state.routes[state.index].key;
const descriptor = descriptors[activeKey];
if (!lastState) {
lastState = {
backProgress: canGoBack ? new Animaged.Value(1) : null,
descriptor,
descriptors,
navigation,
transition: null,
layout: {
height: new Animated.Value(0),
initHeight: 0,
initWidth: 0,
isMeasured: false,
width: new Animated.Value(0),
},
};
}
// const lastNavState = this.props.navigation.state;
const lastNavState = lastState.navigation.state;
const lastActiveKey = lastNavState.routes[lastNavState.index].key;
// const transitionFromKey =
// lastActiveKey !== activeKey ? lastActiveKey : null;
const transitionFromKey = state.transitioningFromKey;
const transitionFromDescriptor =
transitionFromKey &&
lastState.descriptor &&
lastState.descriptor.key === transitionFromKey;
// We can only perform a transition if we have been told to via state.transitioningFromKey, and if our previous descriptor matches, indicating that the transitioningFromKey is currently being presented.
if (transitionFromDescriptor) {
if (lastState.transition) {
// there is already a transition in progress.. Don't interrupt it!
// At the end of the transition, we will compare props and start again
return lastState;
}
return {
...lastState,
navigation,
descriptor,
backProgress: null,
transition: {
fromDescriptor: lastState.descriptor,
toDescriptor: descriptor,
progress: new Animated.Value(0),
},
};
}
// No transition is being performed. If the key has changed, present it immediately without transition
if (lastActiveKey !== activeKey) {
return {
...lastState,
backProgress: canGoBack ? new Animaged.Value(1) : null,
descriptor,
transition: null,
};
}
return lastState;
};
// React doesn't handle getDerivedStateFromProps yet, but the polyfill is simple..
state = Transitioner.getDerivedStateFromProps(this.props);
componentWillReceiveProps(nextProps) {
const nextState = Transitioner.getDerivedStateFromProps(
nextProps,
this.state
);
if (this.state !== nextState) {
this.setState(nextState);
}
}
_startTransition(transition) {
const { configureTransition } = this.props;
const { descriptors } = this.state;
const { progress, fromDescriptor, toDescriptor } = transition;
progress.setValue(0);
// get the transition spec.
// passing the new transitionProps format (this.state) into configureTransition is a breaking change that I haven't documented yet!
const transitionUserSpec =
(configureTransition && configureTransition(this.state)) || null;
const transitionSpec = {
...DefaultTransitionSpec,
...transitionUserSpec,
};
const { timing } = transitionSpec;
// mutating a prop, this is terrible!
// it was in the previous transitioner implementation, so I'm leaving it as-is for now:
delete transitionSpec.timing;
timing(progress, {
...transitionSpec,
toValue: 1,
}).start(didComplete => {
this._completeTransition(transition, didComplete);
});
}
_completeTransition(transition, didComplete) {
if (!this._isMounted) {
return;
}
const { progress, fromDescriptor, toDescriptor } = transition;
const { navigation, descriptors } = this.props;
const nextState = navigation.state;
const activeKey = nextState.routes[nextState.index].key;
const nextDescriptor =
descriptors[activeKey] || this.state.descriptors[activeKey];
if (activeKey !== toDescriptor.key) {
// The user has changed navigation states during the transition! This is known as a queued transition.
// Now we set state for a new transition to the current navigation state
this.setState({
navigation,
descriptors,
descriptor: nextDescriptor,
transition: {
fromDescriptor: toDescriptor,
toDescriptor: nextDescriptor,
progress: new Animated.Value(0),
},
backProgress: null,
});
return;
}
const canGoBack = navigation.state.index > 0;
// All transitions are complete. Reset to normal state:
this.setState({
navigation,
descriptors,
descriptor: nextDescriptor,
transition: null,
backProgress: canGoBack ? new Animated.Value(1) : null,
});
}
render() {
console.log('Rendering Transitioner', this.state);
return (
<View onLayout={this._onLayout} style={[styles.main]}>
{this.props.render(this.state)}
</View>
);
}
componentDidUpdate(lastProps, lastState) {
// start transition if it needs it
if (
this.state.transition &&
(!lastState.transition ||
lastState.transition.toDescriptor !==
this.state.transition.toDescriptor)
) {
this._startTransition(this.state.transition);
}
}
_onLayout = event => {
const lastLayout = this.state.layout;
const { height, width } = event.nativeEvent.layout;
if (lastLayout.initWidth === width && lastLayout.initHeight === height) {
return;
}
const layout = {
...lastLayout,
initHeight: height,
initWidth: width,
isMeasured: true,
};
layout.height.setValue(height);
layout.width.setValue(width);
this.setState({ layout });
};
}
const styles = StyleSheet.create({
main: {
flex: 1,
},
});
export default Transitioner;

View File

@@ -11,77 +11,24 @@ const MIN_POSITION_OFFSET = 0.01;
*/
export default function createPointerEventsContainer(Component) {
class Container extends React.Component {
constructor(props, context) {
super(props, context);
this._pointerEvents = this._computePointerEvents();
}
componentWillMount() {
this._onPositionChange = this._onPositionChange.bind(this);
this._onComponentRef = this._onComponentRef.bind(this);
}
componentDidMount() {
this._bindPosition(this.props);
}
componentWillUnmount() {
this._positionListener && this._positionListener.remove();
}
componentWillReceiveProps(nextProps) {
this._bindPosition(nextProps);
}
render() {
this._pointerEvents = this._computePointerEvents();
return (
<Component
{...this.props}
pointerEvents={this._pointerEvents}
onComponentRef={this._onComponentRef}
/>
<Component {...this.props} pointerEvents={this._getPointerEvents()} />
);
}
_onComponentRef(component) {
this._component = component;
if (component) {
invariant(
typeof component.setNativeProps === 'function',
'component must implement method `setNativeProps`'
);
}
}
_bindPosition(props) {
this._positionListener && this._positionListener.remove();
this._positionListener = new AnimatedValueSubscription(
props.position,
this._onPositionChange
_getPointerEvents() {
const { navigation, descriptor, transition } = this.props;
const { state } = navigation;
const descriptorIndex = navigation.state.routes.findIndex(
r => r.key === descriptor.key
);
}
_onPositionChange() {
if (this._component) {
const pointerEvents = this._computePointerEvents();
if (this._pointerEvents !== pointerEvents) {
this._pointerEvents = pointerEvents;
this._component.setNativeProps({ pointerEvents });
}
}
}
_computePointerEvents() {
const { navigation, position, scene } = this.props;
if (scene.isStale || navigation.state.index !== scene.index) {
if (descriptorIndex !== state.index) {
// The scene isn't focused.
return scene.index > navigation.state.index ? 'box-only' : 'none';
return descriptorIndex > state.index ? 'box-only' : 'none';
}
const offset = position.__getAnimatedValue() - navigation.state.index;
if (Math.abs(offset) > MIN_POSITION_OFFSET) {
if (transition) {
// The positon is still away from scene's index.
// Scene's children should not receive touches until the position
// is close enough to scene's index.