From 0ea01673e5d8d35b445f191b998d0298bff67a4b Mon Sep 17 00:00:00 2001 From: Eric Vicenti Date: Sun, 11 Mar 2018 00:11:58 -0800 Subject: [PATCH] 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 --- examples/NavigationPlayground/js/App.js | 3 +- src/__tests__/NavigationStateUtils-test.js | 28 - src/getChildEventSubscriber.js | 2 +- src/navigators/createStackNavigator.js | 2 +- src/routers/StackRouter.js | 23 +- src/routers/TabRouter.js | 2 +- src/routers/__tests__/DrawerRouter-test.js | 4 +- src/routers/__tests__/Routers-test.js | 2 +- src/routers/__tests__/StackRouter-test.js | 64 +- src/views/Header/Header2.js | 594 ++++++++++++++++++ src/views/Header/HeaderStyleInterpolator2.js | 176 ++++++ src/views/StackView/StackView2.js | 546 ++++++++++++++++ .../StackView/StackViewTransitionConfigs.js | 41 +- src/views/StackView/StackViewTransitions.js | 268 ++++++++ src/views/StackView/Transitioner2.js | 225 +++++++ .../StackView/createPointerEventsContainer.js | 71 +-- 16 files changed, 1886 insertions(+), 165 deletions(-) create mode 100644 src/views/Header/Header2.js create mode 100644 src/views/Header/HeaderStyleInterpolator2.js create mode 100644 src/views/StackView/StackView2.js create mode 100644 src/views/StackView/StackViewTransitions.js create mode 100644 src/views/StackView/Transitioner2.js diff --git a/examples/NavigationPlayground/js/App.js b/examples/NavigationPlayground/js/App.js index 4769706b..cd3627ea 100644 --- a/examples/NavigationPlayground/js/App.js +++ b/examples/NavigationPlayground/js/App.js @@ -305,7 +305,8 @@ const AppNavigator = StackNavigator( } ); -export default () => ; +// export default () => ; +export default SimpleStack; const styles = StyleSheet.create({ item: { diff --git a/src/__tests__/NavigationStateUtils-test.js b/src/__tests__/NavigationStateUtils-test.js index d5fec1f5..a295bc3e 100644 --- a/src/__tests__/NavigationStateUtils-test.js +++ b/src/__tests__/NavigationStateUtils-test.js @@ -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( diff --git a/src/getChildEventSubscriber.js b/src/getChildEventSubscriber.js index 0900bfe9..772e7c1d 100644 --- a/src/getChildEventSubscriber.js +++ b/src/getChildEventSubscriber.js @@ -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; diff --git a/src/navigators/createStackNavigator.js b/src/navigators/createStackNavigator.js index f87a8bb4..e50e5a2e 100644 --- a/src/navigators/createStackNavigator.js +++ b/src/navigators/createStackNavigator.js @@ -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 = {}) { diff --git a/src/routers/StackRouter.js b/src/routers/StackRouter.js index 9d86ca53..2cc14d8c 100644 --- a/src/routers/StackRouter.js +++ b/src/routers/StackRouter.js @@ -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 && diff --git a/src/routers/TabRouter.js b/src/routers/TabRouter.js index 85eea076..389cb240 100644 --- a/src/routers/TabRouter.js +++ b/src/routers/TabRouter.js @@ -67,7 +67,7 @@ export default (routeConfigs, config = {}) => { state = { routes, index: initialRouteIndex, - isTransitioning: false, + transitioningFromKey: null, }; // console.log(`${order.join('-')}: Initial state`, {state}); } diff --git a/src/routers/__tests__/DrawerRouter-test.js b/src/routers/__tests__/DrawerRouter-test.js index 704e7e2f..dcda1407 100644 --- a/src/routers/__tests__/DrawerRouter-test.js +++ b/src/routers/__tests__/DrawerRouter-test.js @@ -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 }, diff --git a/src/routers/__tests__/Routers-test.js b/src/routers/__tests__/Routers-test.js index 021a730b..2491de80 100644 --- a/src/routers/__tests__/Routers-test.js +++ b/src/routers/__tests__/Routers-test.js @@ -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: [ { diff --git a/src/routers/__tests__/StackRouter-test.js b/src/routers/__tests__/StackRouter-test.js index 9fcc675f..10937f2b 100644 --- a/src/routers/__tests__/StackRouter-test.js +++ b/src/routers/__tests__/StackRouter-test.js @@ -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); }); diff --git a/src/views/Header/Header2.js b/src/views/Header/Header2.js new file mode 100644 index 00000000..0d8c10ee --- /dev/null +++ b/src/views/Header/Header2.js @@ -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 ( + + {titleString} + + ); + }; + + _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 ( + + ); + }; + + _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 ( + + ); + }; + + _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 }) => ( + + {children} + + ); + + const LabelContainer = ({ children }) => ( + + {children} + + ); + + 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 ( + + {subView} + + ); + } + + _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 ( + + {subView} + + ); + } + + _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 ( + + {title} + {left} + {right} + + ); + } else { + return ( + + + + + } + > + {title} + {left} + {right} + + ); + } + } + + 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 ( + + {options.headerBackground} + {appBar} + + ); + } +} + +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); diff --git a/src/views/Header/HeaderStyleInterpolator2.js b/src/views/Header/HeaderStyleInterpolator2.js new file mode 100644 index 00000000..90de49e5 --- /dev/null +++ b/src/views/Header/HeaderStyleInterpolator2.js @@ -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, +}; diff --git a/src/views/StackView/StackView2.js b/src/views/StackView/StackView2.js new file mode 100644 index 00000000..d6828a7e --- /dev/null +++ b/src/views/StackView/StackView2.js @@ -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 =>
); + 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 ( + + + {backwardScene && this._renderScene(backwardScene, index - 1)} + {this._renderScene(descriptor, index)} + {forwardScene && this._renderScene(forwardScene, index + 1)} + + {floatingHeader} + + ); + } + + _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 ( + + + + + {this._renderHeader(descriptor, headerMode)} + + ); + } + return ( + + ); + } + + _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 ( + + {this._renderInnerScene(descriptor)} + + ); + }; +} + +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 ( + { + // 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 ; + }; +} + +export default StackView; diff --git a/src/views/StackView/StackViewTransitionConfigs.js b/src/views/StackView/StackViewTransitionConfigs.js index 8407e50d..ec9f810e 100644 --- a/src/views/StackView/StackViewTransitionConfigs.js +++ b/src/views/StackView/StackViewTransitionConfigs.js @@ -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; diff --git a/src/views/StackView/StackViewTransitions.js b/src/views/StackView/StackViewTransitions.js new file mode 100644 index 00000000..0abd1a71 --- /dev/null +++ b/src/views/StackView/StackViewTransitions.js @@ -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, +}; diff --git a/src/views/StackView/Transitioner2.js b/src/views/StackView/Transitioner2.js new file mode 100644 index 00000000..5007f483 --- /dev/null +++ b/src/views/StackView/Transitioner2.js @@ -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 ( + + {this.props.render(this.state)} + + ); + } + + 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; diff --git a/src/views/StackView/createPointerEventsContainer.js b/src/views/StackView/createPointerEventsContainer.js index 045a012f..ea90aae1 100644 --- a/src/views/StackView/createPointerEventsContainer.js +++ b/src/views/StackView/createPointerEventsContainer.js @@ -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 ( - + ); } - _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.