diff --git a/package.json b/package.json index 32dfc7d35..0b15c595e 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "react": "16.5.0", "react-dom": "16.5.0", "react-native": "~0.57.7", - "react-native-gesture-handler": "^1.1.0", + "react-native-gesture-handler": "^1.2.1", + "react-native-reanimated": "^1.0.1", "react-native-screens": "^1.0.0-alpha.22", "react-test-renderer": "16.5.0", "release-it": "^11.0.0", @@ -76,6 +77,7 @@ "react": "*", "react-native": "*", "react-native-gesture-handler": "^1.0.0", + "react-native-reanimated": "^1.0.0", "react-native-screens": "^1.0.0 || ^1.0.0-alpha" }, "jest": { diff --git a/src/TransitionConfigs/CardStyleInterpolators.tsx b/src/TransitionConfigs/CardStyleInterpolators.tsx new file mode 100644 index 000000000..ad4168062 --- /dev/null +++ b/src/TransitionConfigs/CardStyleInterpolators.tsx @@ -0,0 +1,140 @@ +import Animated from 'react-native-reanimated'; +import { CardInterpolationProps, CardInterpolatedStyle } from '../types'; + +const { cond, multiply, interpolate } = Animated; + +/** + * Standard iOS-style slide in from the right. + */ +export function forHorizontalIOS({ + positions: { current, next }, + layout, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateFocused = interpolate(current, { + inputRange: [0, 1], + outputRange: [layout.width, 0], + }); + const translateUnfocused = next + ? interpolate(next, { + inputRange: [0, 1], + outputRange: [0, multiply(layout.width, -0.3)], + }) + : 0; + + const opacity = interpolate(current, { + inputRange: [0, 1], + outputRange: [0, 0.07], + }); + + const shadowOpacity = interpolate(current, { + inputRange: [0, 1], + outputRange: [0, 0.3], + }); + + return { + cardStyle: { + backgroundColor: '#eee', + transform: [ + // Translation for the animation of the current card + { translateX: translateFocused }, + // Translation for the animation of the card on top of this + { translateX: translateUnfocused }, + ], + shadowOpacity, + }, + overlayStyle: { opacity }, + }; +} + +/** + * Standard iOS-style slide in from the bottom (used for modals). + */ +export function forVerticalIOS({ + positions: { current }, + layout, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [layout.height, 0], + }); + + return { + cardStyle: { + backgroundColor: '#eee', + transform: [ + // Translation for the animation of the current card + { translateY }, + ], + }, + }; +} + +/** + * Standard Android-style fade in from the bottom for Android Oreo. + */ +export function forFadeFromBottomAndroid({ + positions: { current }, + layout, + closing, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [multiply(layout.height, 0.08), 0], + }); + + const opacity = cond( + closing, + current, + interpolate(current, { + inputRange: [0, 0.5, 0.9, 1], + outputRange: [0, 0.25, 0.7, 1], + }) + ); + + return { + cardStyle: { + opacity, + transform: [{ translateY }], + }, + }; +} + +/** + * Standard Android-style wipe from the bottom for Android Pie. + */ +export function forWipeFromBottomAndroid({ + positions: { current, next }, + layout, +}: CardInterpolationProps): CardInterpolatedStyle { + const containerTranslateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [layout.height, 0], + }); + const cardTranslateYFocused = interpolate(current, { + inputRange: [0, 1], + outputRange: [multiply(layout.height, 95.9 / 100, -1), 0], + }); + const cardTranslateYUnfocused = next + ? interpolate(next, { + inputRange: [0, 1], + outputRange: [0, multiply(layout.height, 2 / 100, -1)], + }) + : 0; + const overlayOpacity = interpolate(current, { + inputRange: [0, 0.36, 1], + outputRange: [0, 0.1, 0.1], + }); + + return { + containerStyle: { + transform: [{ translateY: containerTranslateY }], + }, + cardStyle: { + transform: [ + { translateY: cardTranslateYFocused }, + { translateY: cardTranslateYUnfocused }, + ], + }, + overlayStyle: { opacity: overlayOpacity }, + }; +} diff --git a/src/TransitionConfigs/HeaderStyleInterpolators.tsx b/src/TransitionConfigs/HeaderStyleInterpolators.tsx new file mode 100644 index 000000000..c5a1f1d0d --- /dev/null +++ b/src/TransitionConfigs/HeaderStyleInterpolators.tsx @@ -0,0 +1,95 @@ +import Animated from 'react-native-reanimated'; +import { HeaderInterpolationProps, HeaderInterpolatedStyle } from '../types'; + +const { interpolate, add } = Animated; + +export function forUIKit({ + positions: { current, next }, + layouts, +}: HeaderInterpolationProps): HeaderInterpolatedStyle { + const leftSpacing = 27; + + // The title and back button title should cross-fade to each other + // When screen is fully open, the title should be in center, and back title should be on left + // When screen is closing, the previous title will animate to back title's position + // And back title will animate to title's position + // We achieve this by calculating the offsets needed to translate title to back title's position and vice-versa + const backTitleOffset = layouts.backTitle + ? (layouts.screen.width - layouts.backTitle.width) / 2 - leftSpacing + : undefined; + const titleLeftOffset = layouts.title + ? (layouts.screen.width - layouts.title.width) / 2 - leftSpacing + : undefined; + + // When the current title is animating to right, it is centered in the right half of screen in middle of transition + // The back title also animates in from this position + const rightOffset = layouts.screen.width / 4; + + const progress = add(current, next ? next : 0); + + return { + leftButtonStyle: { + opacity: interpolate(progress, { + inputRange: [0.3, 1, 1.5], + outputRange: [0, 1, 0], + }), + }, + backTitleStyle: { + // Title and back title are a bit different width due to title being bold + // Adjusting the letterSpacing makes them coincide better + letterSpacing: backTitleOffset + ? interpolate(progress, { + inputRange: [0.3, 1, 2], + outputRange: [0.35, 0, 0], + }) + : 0, + transform: [ + { + // Avoid translating if we don't have its width + // It means there's no back title set + translateX: backTitleOffset + ? interpolate(progress, { + inputRange: [0, 1, 2], + outputRange: [backTitleOffset, 0, -rightOffset], + }) + : 0, + }, + ], + }, + titleStyle: { + opacity: interpolate(progress, { + inputRange: [0.4, 1, 1.5], + outputRange: [0, 1, 0], + }), + transform: [ + { + translateX: titleLeftOffset + ? interpolate(progress, { + inputRange: [0.5, 1, 2], + outputRange: [rightOffset, 0, -titleLeftOffset], + }) + : 0, + }, + ], + }, + }; +} + +export function forFade({ + positions: { current, next }, +}: HeaderInterpolationProps): HeaderInterpolatedStyle { + const progress = add(current, next ? next : 0); + const opacity = interpolate(progress, { + inputRange: [0, 1, 2], + outputRange: [0, 1, 0], + }); + + return { + leftButtonStyle: { opacity }, + titleStyle: { opacity }, + }; +} + +export function forNoAnimation(): HeaderInterpolatedStyle { + return {}; +} diff --git a/src/TransitionConfigs/TransitionPresets.tsx b/src/TransitionConfigs/TransitionPresets.tsx new file mode 100644 index 000000000..274bcf9a2 --- /dev/null +++ b/src/TransitionConfigs/TransitionPresets.tsx @@ -0,0 +1,73 @@ +import { + forHorizontalIOS, + forVerticalIOS, + forWipeFromBottomAndroid, + forFadeFromBottomAndroid, +} from './CardStyleInterpolators'; +import { forUIKit, forNoAnimation } from './HeaderStyleInterpolators'; +import { + TransitionIOSSpec, + WipeFromBottomAndroidSpec, + FadeOutToBottomAndroidSpec, + FadeInFromBottomAndroidSpec, +} from './TransitionSpecs'; +import { TransitionPreset } from '../types'; +import { Platform } from 'react-native'; + +const ANDROID_VERSION_PIE = 28; + +// Standard iOS navigation transition +export const SlideFromRightIOS: TransitionPreset = { + direction: 'horizontal', + headerMode: 'float', + transitionSpec: { + open: TransitionIOSSpec, + close: TransitionIOSSpec, + }, + cardStyleInterpolator: forHorizontalIOS, + headerStyleInterpolator: forUIKit, +}; + +// Standard iOS navigation transition for modals +export const ModalSlideFromBottomIOS: TransitionPreset = { + direction: 'vertical', + headerMode: 'screen', + transitionSpec: { + open: TransitionIOSSpec, + close: TransitionIOSSpec, + }, + cardStyleInterpolator: forVerticalIOS, + headerStyleInterpolator: forNoAnimation, +}; + +// Standard Android navigation transition when opening or closing an Activity on Android < 9 +export const FadeFromBottomAndroid: TransitionPreset = { + direction: 'vertical', + headerMode: 'screen', + transitionSpec: { + open: FadeInFromBottomAndroidSpec, + close: FadeOutToBottomAndroidSpec, + }, + cardStyleInterpolator: forFadeFromBottomAndroid, + headerStyleInterpolator: forNoAnimation, +}; + +// Standard Android navigation transition when opening or closing an Activity on Android >= 9 +export const WipeFromBottomAndroid: TransitionPreset = { + direction: 'vertical', + headerMode: 'screen', + transitionSpec: { + open: WipeFromBottomAndroidSpec, + close: WipeFromBottomAndroidSpec, + }, + cardStyleInterpolator: forWipeFromBottomAndroid, + headerStyleInterpolator: forNoAnimation, +}; + +export const DefaultTransition = Platform.select({ + ios: SlideFromRightIOS, + default: + Platform.OS === 'android' && Platform.Version < ANDROID_VERSION_PIE + ? FadeFromBottomAndroid + : WipeFromBottomAndroid, +}); diff --git a/src/TransitionConfigs/TransitionSpecs.tsx b/src/TransitionConfigs/TransitionSpecs.tsx new file mode 100644 index 000000000..426b541c2 --- /dev/null +++ b/src/TransitionConfigs/TransitionSpecs.tsx @@ -0,0 +1,43 @@ +import { Easing } from 'react-native-reanimated'; +import { TransitionSpec } from '../types'; + +export const TransitionIOSSpec: TransitionSpec = { + timing: 'spring', + config: { + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, + }, +}; + +// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_open_enter.xml +export const FadeInFromBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 350, + easing: Easing.out(Easing.poly(5)), + }, +}; + +// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_close_exit.xml +export const FadeOutToBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 150, + easing: Easing.in(Easing.linear), + }, +}; + +// See http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/res/res/anim/activity_open_enter.xml +export const WipeFromBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 425, + // This is super rough approximation of the path used for the curve by android + // See http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/res/res/interpolator/fast_out_extra_slow_in.xml + easing: Easing.bezier(0.35, 0.45, 0, 1), + }, +}; diff --git a/src/index.tsx b/src/index.tsx index a1f317f85..b7fef1563 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,28 +14,3 @@ export const Assets = Platform.select({ ], default: [require('./views/assets/back-icon.png')], }); - -/** - * Views - */ -export { default as Header } from './views/Header/Header'; -export { default as HeaderBackButton } from './views/Header/HeaderBackButton'; -export { default as HeaderTitle } from './views/Header/HeaderTitle'; -export { - default as HeaderStyleInterpolator, -} from './views/Header/HeaderStyleInterpolator'; -export { default as StackView } from './views/StackView/StackView'; -export { default as StackViewCard } from './views/StackView/StackViewCard'; -export { default as StackViewLayout } from './views/StackView/StackViewLayout'; -export { - default as StackViewStyleInterpolator, -} from './views/StackView/StackViewStyleInterpolator'; -export { - default as StackViewTransitionConfigs, -} from './views/StackView/StackViewTransitionConfigs'; -export { - default as createPointerEventsContainer, -} from './views/StackView/createPointerEventsContainer'; -export { default as Transitioner } from './views/Transitioner'; -export { default as ScenesReducer } from './views/ScenesReducer'; -export { default as StackGestureContext } from './utils/StackGestureContext'; diff --git a/src/index.web.tsx b/src/index.web.tsx index 185672f9e..e7413a30a 100644 --- a/src/index.web.tsx +++ b/src/index.web.tsx @@ -6,28 +6,3 @@ export { } from './navigators/createStackNavigator'; export const Assets = []; - -/** - * Views - */ -export { default as Header } from './views/Header/Header'; -export { default as HeaderBackButton } from './views/Header/HeaderBackButton'; -export { default as HeaderTitle } from './views/Header/HeaderTitle'; -export { - default as HeaderStyleInterpolator, -} from './views/Header/HeaderStyleInterpolator'; -export { default as StackView } from './views/StackView/StackView'; -export { default as StackViewCard } from './views/StackView/StackViewCard'; -export { default as StackViewLayout } from './views/StackView/StackViewLayout'; -export { - default as StackViewStyleInterpolator, -} from './views/StackView/StackViewStyleInterpolator'; -export { - default as StackViewTransitionConfigs, -} from './views/StackView/StackViewTransitionConfigs'; -export { - default as createPointerEventsContainer, -} from './views/StackView/createPointerEventsContainer'; -export { default as Transitioner } from './views/Transitioner'; -export { default as ScenesReducer } from './views/ScenesReducer'; -export { default as StackGestureContext } from './utils/StackGestureContext'; diff --git a/src/navigators/createStackNavigator.tsx b/src/navigators/createStackNavigator.tsx index 29aab4412..9d598c334 100644 --- a/src/navigators/createStackNavigator.tsx +++ b/src/navigators/createStackNavigator.tsx @@ -1,7 +1,7 @@ import { StackRouter, createNavigator } from '@react-navigation/core'; import { createKeyboardAwareNavigator } from '@react-navigation/native'; import { Platform } from 'react-native'; -import StackView from '../views/StackView/StackView'; +import StackView from '../views/Stack/StackView'; import { NavigationStackOptions, NavigationProp, Screen } from '../types'; function createStackNavigator( diff --git a/src/types.tsx b/src/types.tsx index fd9a9a411..55a8b565b 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1,4 +1,5 @@ -import { Animated, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { StyleProp, TextStyle, ViewStyle } from 'react-native'; +import Animated from 'react-native-reanimated'; import { SafeAreaView } from '@react-navigation/native'; export type Route = { @@ -25,7 +26,10 @@ export type NavigationState = { key: string; index: number; routes: Route[]; - isTransitioning?: boolean; + transitions: { + pushing: string[]; + popping: string[]; + }; params?: { [key: string]: unknown }; }; @@ -45,42 +49,22 @@ export type NavigationProp = { dangerouslyGetParent(): NavigationProp | undefined; }; -export type HeaderMode = 'float' | 'screen'; - -export type HeaderLayoutPreset = 'left' | 'center'; +export type Layout = { width: number; height: number }; -export type HeaderTransitionPreset = 'fade-in-place' | 'uikit'; +export type GestureDirection = 'horizontal' | 'vertical'; -export type HeaderBackgroundTransitionPreset = 'translate' | 'fade'; +export type HeaderMode = 'float' | 'screen' | 'none'; export type HeaderProps = { mode: HeaderMode; - position: Animated.Value; navigation: NavigationProp; - layout: TransitionerLayout; + layout: Layout; scene: Scene; scenes: Scene[]; - layoutPreset: HeaderLayoutPreset; - transitionPreset?: HeaderTransitionPreset; backTitleVisible?: boolean; - leftInterpolator: (props: SceneInterpolatorProps) => any; - titleInterpolator: (props: SceneInterpolatorProps) => any; - rightInterpolator: (props: SceneInterpolatorProps) => any; - backgroundInterpolator: (props: SceneInterpolatorProps) => any; isLandscape: boolean; }; -export type HeaderTransitionConfig = { - headerLeftInterpolator: SceneInterpolator; - headerLeftLabelInterpolator: SceneInterpolator; - headerLeftButtonInterpolator: SceneInterpolator; - headerTitleFromLeftInterpolator: SceneInterpolator; - headerTitleInterpolator: SceneInterpolator; - headerRightInterpolator: SceneInterpolator; - headerBackgroundInterpolator: SceneInterpolator; - headerLayoutInterpolator: SceneInterpolator; -}; - export type NavigationStackOptions = { title?: string; header?: (props: HeaderProps) => React.ReactNode; @@ -118,19 +102,7 @@ export type NavigationStackOptions = { export type NavigationConfig = { mode: 'card' | 'modal'; headerMode: HeaderMode; - headerLayoutPreset: HeaderLayoutPreset; - headerTransitionPreset: HeaderTransitionPreset; - headerBackgroundTransitionPreset: HeaderBackgroundTransitionPreset; headerBackTitleVisible?: boolean; - cardShadowEnabled?: boolean; - cardOverlayEnabled?: boolean; - onTransitionStart?: () => void; - onTransitionEnd?: () => void; - transitionConfig: ( - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps, - isModal?: boolean - ) => HeaderTransitionConfig; }; export type SceneDescriptor = { @@ -151,51 +123,85 @@ export type HeaderBackbuttonProps = { backTitleVisible?: boolean; allowFontScaling?: boolean; titleStyle?: StyleProp; - layoutPreset: HeaderLayoutPreset; width?: number; scene: Scene; }; -export type SceneInterpolatorProps = { - mode?: HeaderMode; - layout: TransitionerLayout; - scene: Scene; - scenes: Scene[]; - position: Animated.AnimatedInterpolation; - navigation: NavigationProp; - shadowEnabled?: boolean; - cardOverlayEnabled?: boolean; +export type Screen = React.ComponentType & { + navigationOptions?: NavigationStackOptions & { + [key: string]: any; + }; }; -export type SceneInterpolator = (props: SceneInterpolatorProps) => any; +export type SpringConfig = { + damping: number; + mass: number; + stiffness: number; + restSpeedThreshold: number; + restDisplacementThreshold: number; + overshootClamping: boolean; +}; -export type TransitionerLayout = { - height: Animated.Value; - width: Animated.Value; - initHeight: number; - initWidth: number; - isMeasured: boolean; +export type TimingConfig = { + duration: number; + easing: Animated.EasingFunction; }; -export type TransitionProps = { - layout: TransitionerLayout; - navigation: NavigationProp; - position: Animated.Value; - scenes: Scene[]; - scene: Scene; - index: number; +export type TransitionSpec = + | { timing: 'spring'; config: SpringConfig } + | { timing: 'timing'; config: TimingConfig }; + +export type CardInterpolationProps = { + positions: { + current: Animated.Node; + next?: Animated.Node; + }; + closing: Animated.Node<0 | 1>; + layout: { + width: Animated.Node; + height: Animated.Node; + }; }; -export type TransitionConfig = { - transitionSpec: { - timing: Function; +export type CardInterpolatedStyle = { + containerStyle?: any; + cardStyle?: any; + overlayStyle?: any; +}; + +export type CardStyleInterpolator = ( + props: CardInterpolationProps +) => CardInterpolatedStyle; + +export type HeaderInterpolationProps = { + positions: { + current: Animated.Node; + next?: Animated.Node; + }; + layouts: { + screen: Layout; + title?: Layout; + backTitle?: Layout; }; - screenInterpolator: SceneInterpolator; - containerStyle?: StyleProp; }; -export type Screen = React.ComponentType & { - navigationOptions?: NavigationStackOptions & { - [key: string]: any; +export type HeaderInterpolatedStyle = { + backTitleStyle?: any; + leftButtonStyle?: any; + titleStyle?: any; +}; + +export type HeaderStyleInterpolator = ( + props: HeaderInterpolationProps +) => HeaderInterpolatedStyle; + +export type TransitionPreset = { + direction: GestureDirection; + headerMode: HeaderMode; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; }; + cardStyleInterpolator: CardStyleInterpolator; + headerStyleInterpolator: HeaderStyleInterpolator; }; diff --git a/src/utils/ReactNativeFeatures.tsx b/src/utils/ReactNativeFeatures.tsx deleted file mode 100644 index fb6481b42..000000000 --- a/src/utils/ReactNativeFeatures.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { NativeModules } from 'react-native'; - -const { PlatformConstants } = NativeModules; - -export const supportsImprovedSpringAnimation = () => { - if (PlatformConstants && PlatformConstants.reactNativeVersion) { - const { major, minor } = PlatformConstants.reactNativeVersion; - return minor >= 50 || (major === 0 && minor === 0); // `master` has major + minor set to 0 - } - return false; -}; diff --git a/src/utils/StackGestureContext.tsx b/src/utils/StackGestureContext.tsx index 2cc0f2d5b..61618d6af 100644 --- a/src/utils/StackGestureContext.tsx +++ b/src/utils/StackGestureContext.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; import { PanGestureHandler } from 'react-native-gesture-handler'; -export default React.createContext | null>(null); +export default React.createContext | undefined>( + undefined +); diff --git a/src/utils/clamp.tsx b/src/utils/clamp.tsx deleted file mode 100644 index e5c5e85ae..000000000 --- a/src/utils/clamp.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function clamp(min: number, value: number, max: number) { - if (value < min) { - return min; - } - if (value > max) { - return max; - } - return value; -} diff --git a/src/utils/getSceneIndicesForInterpolationInputRange.tsx b/src/utils/getSceneIndicesForInterpolationInputRange.tsx deleted file mode 100644 index 1afa4104c..000000000 --- a/src/utils/getSceneIndicesForInterpolationInputRange.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Scene } from '../types'; - -type Props = { - scene: Scene; - scenes: Scene[]; -}; - -function getSceneIndicesForInterpolationInputRange(props: Props) { - const { scene, scenes } = props; - const index = scene.index; - const lastSceneIndexInScenes = scenes.length - 1; - const isBack = !scenes[lastSceneIndexInScenes].isActive; - - if (isBack) { - const currentSceneIndexInScenes = scenes.findIndex(item => item === scene); - const targetSceneIndexInScenes = scenes.findIndex(item => item.isActive); - const targetSceneIndex = scenes[targetSceneIndexInScenes].index; - const lastSceneIndex = scenes[lastSceneIndexInScenes].index; - - if ( - index !== targetSceneIndex && - currentSceneIndexInScenes === lastSceneIndexInScenes - ) { - return { - first: Math.min(targetSceneIndex, index - 1), - last: index + 1, - }; - } else if ( - index === targetSceneIndex && - currentSceneIndexInScenes === targetSceneIndexInScenes - ) { - return { - first: index - 1, - last: Math.max(lastSceneIndex, index + 1), - }; - } else if ( - index === targetSceneIndex || - currentSceneIndexInScenes > targetSceneIndexInScenes - ) { - return null; - } else { - return { first: index - 1, last: index + 1 }; - } - } else { - return { first: index - 1, last: index + 1 }; - } -} - -export default getSceneIndicesForInterpolationInputRange; diff --git a/src/utils/memoize.tsx b/src/utils/memoize.tsx new file mode 100644 index 000000000..6d5e22aff --- /dev/null +++ b/src/utils/memoize.tsx @@ -0,0 +1,33 @@ +export default function memoize>( + callback: (...deps: Deps) => Result +) { + let previous: Deps | undefined; + let result: Result | undefined; + + return (...dependencies: Deps): Result => { + let hasChanged = false; + + if (previous) { + if (previous.length !== dependencies.length) { + hasChanged = true; + } else { + for (let i = 0; i < previous.length; i++) { + if (previous[i] !== dependencies[i]) { + hasChanged = true; + break; + } + } + } + } else { + hasChanged = true; + } + + previous = dependencies; + + if (hasChanged || result === undefined) { + result = callback(...dependencies); + } + + return result; + }; +} diff --git a/src/utils/shallowEqual.tsx b/src/utils/shallowEqual.tsx deleted file mode 100644 index 31425961b..000000000 --- a/src/utils/shallowEqual.tsx +++ /dev/null @@ -1,58 +0,0 @@ -const hasOwnProperty = Object.prototype.hasOwnProperty; - -/** - * inlined Object.is polyfill to avoid requiring consumers ship their own - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is - */ -function is(x: any, y: any) { - // SameValue algorithm - if (x === y) { - // Steps 1-5, 7-10 - // Steps 6.b-6.e: +0 != -0 - // Added the nonzero y check to make Flow happy, but it is redundant - return x !== 0 || y !== 0 || 1 / x === 1 / y; - } else { - // Step 6.a: NaN == NaN - return x !== x && y !== y; - } -} - -/** - * Performs equality by iterating through keys on an object and returning false - * when any key has values which are not strictly equal between the arguments. - * Returns true when the values of all keys are strictly equal. - */ -function shallowEqual(objA: any, objB: any) { - if (is(objA, objB)) { - return true; - } - if ( - typeof objA !== 'object' || - objA === null || - typeof objB !== 'object' || - objB === null - ) { - return false; - } - - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - if (keysA.length !== keysB.length) { - return false; - } - - // Test for A's keys different from B. - for (let i = 0; i < keysA.length; i++) { - if ( - !hasOwnProperty.call(objB, keysA[i]) || - !is(objA[keysA[i]], objB[keysA[i]]) - ) { - return false; - } - } - - return true; -} - -export default shallowEqual; diff --git a/src/views/Header/BackButtonWeb.tsx b/src/views/Header/BackButtonWeb.tsx deleted file mode 100644 index 186a989e3..000000000 --- a/src/views/Header/BackButtonWeb.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; - -type Props = { - tintColor: string; -}; - -export default function BackButton({ tintColor }: Props) { - return ( - - - - ); -} diff --git a/src/views/Header/Header.tsx b/src/views/Header/Header.tsx deleted file mode 100644 index 630e8490b..000000000 --- a/src/views/Header/Header.tsx +++ /dev/null @@ -1,820 +0,0 @@ -import * as React from 'react'; - -import { - Animated, - Image, - Platform, - StyleSheet, - View, - I18nManager, - MaskedViewIOS, - ViewStyle, - LayoutChangeEvent, - StyleProp, -} from 'react-native'; - -import { withOrientation, SafeAreaView } from '@react-navigation/native'; - -import HeaderTitle from './HeaderTitle'; -import HeaderBackButton from './HeaderBackButton'; -import ModularHeaderBackButton from './ModularHeaderBackButton'; -import HeaderStyleInterpolator from './HeaderStyleInterpolator'; -import { - Scene, - HeaderLayoutPreset, - SceneInterpolatorProps, - HeaderProps, -} from '../../types'; - -type Props = HeaderProps & { - leftLabelInterpolator: (props: SceneInterpolatorProps) => any; - leftButtonInterpolator: (props: SceneInterpolatorProps) => any; - titleFromLeftInterpolator: (props: SceneInterpolatorProps) => any; - layoutInterpolator: (props: SceneInterpolatorProps) => any; -}; - -type SubviewProps = { - position: Animated.AnimatedInterpolation; - scene: Scene; - style?: StyleProp; -}; - -type SubviewName = 'left' | 'right' | 'title' | 'background'; - -type State = { - widths: { [key: string]: number }; -}; - -const APPBAR_HEIGHT = Platform.select({ - ios: 44, - android: 56, - default: 64, -}); -const STATUSBAR_HEIGHT = Platform.select({ - ios: 20, - default: 0, -}); - -// These can be adjusted by using headerTitleContainerStyle on navigationOptions -const TITLE_OFFSET_CENTER_ALIGN = Platform.select({ - ios: 70, - default: 56, -}); - -const TITLE_OFFSET_LEFT_ALIGN = Platform.select({ - ios: 20, - android: 56, - default: 64, -}); - -const getTitleOffsets = ( - layoutPreset: HeaderLayoutPreset, - hasLeftComponent: boolean, - hasRightComponent: boolean -): ViewStyle | undefined => { - if (layoutPreset === 'left') { - // Maybe at some point we should do something different if the back title is - // explicitly enabled, for now people can control it manually - - let style = { - left: TITLE_OFFSET_LEFT_ALIGN, - right: TITLE_OFFSET_LEFT_ALIGN, - }; - - if (!hasLeftComponent) { - style.left = Platform.OS === 'web' ? 16 : 0; - } - if (!hasRightComponent) { - style.right = 0; - } - - return style; - } else if (layoutPreset === 'center') { - let style = { - left: TITLE_OFFSET_CENTER_ALIGN, - right: TITLE_OFFSET_CENTER_ALIGN, - }; - if (!hasLeftComponent && !hasRightComponent) { - style.left = 0; - style.right = 0; - } - - return style; - } - - return undefined; -}; - -const getAppBarHeight = (isLandscape: boolean) => { - if (Platform.OS === 'ios') { - // @ts-ignore - if (isLandscape && !Platform.isPad) { - return 32; - } else { - return 44; - } - } else if (Platform.OS === 'android') { - return 56; - } else { - return 64; - } -}; - -class Header extends React.PureComponent { - static get HEIGHT() { - return APPBAR_HEIGHT + STATUSBAR_HEIGHT; - } - - static defaultProps = { - layoutInterpolator: HeaderStyleInterpolator.forLayout, - leftInterpolator: HeaderStyleInterpolator.forLeft, - leftButtonInterpolator: HeaderStyleInterpolator.forLeftButton, - leftLabelInterpolator: HeaderStyleInterpolator.forLeftLabel, - titleFromLeftInterpolator: HeaderStyleInterpolator.forCenterFromLeft, - titleInterpolator: HeaderStyleInterpolator.forCenter, - rightInterpolator: HeaderStyleInterpolator.forRight, - backgroundInterpolator: HeaderStyleInterpolator.forBackground, - }; - - state: State = { - widths: {}, - }; - - private getHeaderTitleString(scene: Scene) { - const options = scene.descriptor.options; - if (typeof options.headerTitle === 'string') { - return options.headerTitle; - } - - if (options.title && typeof options.title !== 'string' && __DEV__) { - throw new Error( - `Invalid title for route "${ - scene.route.routeName - }" - title must be string or null, instead it was of type ${typeof options.title}` - ); - } - - return options.title; - } - - private getLastScene(scene: Scene) { - return this.props.scenes.find(s => s.index === scene.index - 1); - } - - private getBackButtonTitleString(scene: Scene) { - const lastScene = this.getLastScene(scene); - if (!lastScene) { - return null; - } - const { headerBackTitle } = lastScene.descriptor.options; - if (headerBackTitle || headerBackTitle === null) { - return headerBackTitle; - } - return this.getHeaderTitleString(lastScene); - } - - private getTruncatedBackButtonTitle(scene: Scene) { - const lastScene = this.getLastScene(scene); - if (!lastScene) { - return null; - } - return lastScene.descriptor.options.headerTruncatedBackTitle; - } - - private renderTitleComponent = (props: SubviewProps) => { - const { layoutPreset } = this.props; - const { options } = props.scene.descriptor; - const headerTitle = options.headerTitle; - if (React.isValidElement(headerTitle)) { - return headerTitle; - } - const titleString = this.getHeaderTitleString(props.scene); - - const titleStyle = options.headerTitleStyle; - const color = options.headerTintColor; - const allowFontScaling = options.headerTitleAllowFontScaling; - - // When title is centered, the width of left/right components depends on the - // calculated size of the title. - const onLayout = - layoutPreset === 'center' - ? (e: LayoutChangeEvent) => { - const { width } = e.nativeEvent.layout; - - this.setState(state => ({ - widths: { - ...state.widths, - [props.scene.key]: width, - }, - })); - } - : undefined; - - const HeaderTitleComponent = - headerTitle && typeof headerTitle !== 'string' - ? headerTitle - : HeaderTitle; - return ( - - {titleString} - - ); - }; - - private renderLeftComponent = (props: SubviewProps) => { - const { options } = props.scene.descriptor; - if ( - React.isValidElement(options.headerLeft) || - options.headerLeft === null - ) { - return options.headerLeft; - } - - if (!options.headerLeft && props.scene.index === 0) { - return; - } - - const backButtonTitle = this.getBackButtonTitleString(props.scene); - const truncatedBackButtonTitle = this.getTruncatedBackButtonTitle( - props.scene - ); - const width = this.state.widths[props.scene.key] - ? (this.props.layout.initWidth - this.state.widths[props.scene.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(() => { - props.scene.descriptor.navigation.goBack(props.scene.descriptor.key); - }); - }; - return ( - - ); - }; - - private renderModularLeftComponent = ( - props: SubviewProps, - ButtonContainerComponent: React.ComponentProps< - typeof ModularHeaderBackButton - >['ButtonContainerComponent'], - LabelContainerComponent: React.ComponentProps< - typeof ModularHeaderBackButton - >['LabelContainerComponent'] - ) => { - const { options, navigation } = props.scene.descriptor; - const backButtonTitle = this.getBackButtonTitleString(props.scene); - const truncatedBackButtonTitle = this.getTruncatedBackButtonTitle( - props.scene - ); - const width = this.state.widths[props.scene.key] - ? (this.props.layout.initWidth - this.state.widths[props.scene.key]) / 2 - : undefined; - - const goBack = () => { - // Go back on next tick because button ripple effect needs to happen on Android - requestAnimationFrame(() => { - navigation.goBack(props.scene.descriptor.key); - }); - }; - - return ( - - ); - }; - - private renderRightComponent = (props: SubviewProps) => { - const { headerRight } = props.scene.descriptor.options; - return headerRight || null; - }; - - private renderLeft = (props: SubviewProps) => { - const { options } = props.scene.descriptor; - - const { transitionPreset } = this.props; - - let { style } = props; - if (options.headerLeftContainerStyle) { - style = [style, options.headerLeftContainerStyle]; - } - - // On Android, or if we have a custom header left, or if we have a custom back image, we - // do not use the modular header (which is the one that imitates UINavigationController) - if ( - transitionPreset !== 'uikit' || - options.headerBackImage || - options.headerLeft || - options.headerLeft === null - ) { - return this.renderSubView( - { ...props, style }, - 'left', - this.renderLeftComponent, - this.props.leftInterpolator - ); - } else { - return this.renderModularSubView( - { ...props, style }, - 'left', - this.renderModularLeftComponent, - this.props.leftLabelInterpolator, - this.props.leftButtonInterpolator - ); - } - }; - - private renderTitle = ( - props: SubviewProps, - options: { - hasLeftComponent: boolean; - hasRightComponent: boolean; - headerTitleContainerStyle: StyleProp; - } - ) => { - const { layoutPreset, transitionPreset } = this.props; - let style: StyleProp = [ - { justifyContent: layoutPreset === 'center' ? 'center' : 'flex-start' }, - getTitleOffsets( - layoutPreset, - options.hasLeftComponent, - options.hasRightComponent - ), - options.headerTitleContainerStyle, - ]; - - return this.renderSubView( - { ...props, style }, - 'title', - this.renderTitleComponent, - transitionPreset === 'uikit' - ? this.props.titleFromLeftInterpolator - : this.props.titleInterpolator - ); - }; - - private renderRight = (props: SubviewProps) => { - const { options } = props.scene.descriptor; - - let { style } = props; - if (options.headerRightContainerStyle) { - style = [style, options.headerRightContainerStyle]; - } - - return this.renderSubView( - { ...props, style }, - 'right', - this.renderRightComponent, - this.props.rightInterpolator - ); - }; - - private renderBackground = (props: SubviewProps) => { - const { - index, - descriptor: { options }, - } = props.scene; - - const offset = this.props.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; - } - - return this.renderSubView( - { ...props, style: StyleSheet.absoluteFill }, - 'background', - () => options.headerBackground, - this.props.backgroundInterpolator - ); - }; - - private renderModularSubView = ( - props: SubviewProps, - name: SubviewName, - renderer: ( - props: SubviewProps, - ButtonContainerComponent: React.ComponentProps< - typeof ModularHeaderBackButton - >['ButtonContainerComponent'], - LabelContainerComponent: React.ComponentProps< - typeof ModularHeaderBackButton - >['LabelContainerComponent'] - ) => React.ReactNode, - labelStyleInterpolator: (props: SceneInterpolatorProps) => any, - buttonStyleInterpolator: (props: SceneInterpolatorProps) => any - ) => { - const { scene } = props; - const { index, isStale, key } = scene; - - // Never render a modular back button on the first screen in a stack. - if (index === 0) { - return; - } - - const offset = this.props.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: React.ReactNode }) => ( - - {children} - - ); - - const LabelContainer = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const subView = renderer( - props, - ButtonContainer as any, - LabelContainer as any - ); - - if (subView === null) { - return subView; - } - - const pointerEvents = offset !== 0 || isStale ? 'none' : 'box-none'; - - return ( - - {subView} - - ); - }; - - private renderSubView = ( - props: SubviewProps, - name: SubviewName, - renderer: (props: SubviewProps) => React.ReactNode, - styleInterpolator: (props: SceneInterpolatorProps) => any - ) => { - const { scene } = props; - const { index, isStale, key } = scene; - - const offset = this.props.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 pointerEvents = offset !== 0 || isStale ? 'none' : 'box-none'; - - return ( - - {subView} - - ); - }; - - private renderHeader = (props: SubviewProps) => { - const { options } = props.scene.descriptor; - if (options.header === null) { - return null; - } - const left = this.renderLeft(props); - const right = this.renderRight(props); - const title = this.renderTitle(props, { - hasLeftComponent: !!left, - hasRightComponent: !!right, - headerTitleContainerStyle: options.headerTitleContainerStyle, - }); - - const { transitionPreset } = this.props; - - const wrapperProps = { - style: styles.header, - key: `scene_${props.scene.key}`, - }; - - if ( - options.headerLeft || - options.headerBackImage || - Platform.OS !== 'ios' || - transitionPreset !== 'uikit' - ) { - return ( - - {title} - {left} - {right} - - ); - } else { - return ( - - - - - } - > - {title} - {left} - {right} - - ); - } - }; - - render() { - let appBar; - let background; - const { mode, scene, isLandscape } = this.props; - - if (mode === 'float') { - const scenesByIndex: { [key: string]: Scene } = {}; - this.props.scenes.forEach(scene => { - scenesByIndex[scene.index] = scene; - }); - const scenesProps = Object.values(scenesByIndex).map(scene => ({ - position: this.props.position, - scene, - })); - appBar = scenesProps.map(props => this.renderHeader(props)); - background = scenesProps.map(props => this.renderBackground(props)); - } else { - const headerProps = { - position: new Animated.Value(this.props.scene.index), - scene: this.props.scene, - }; - - appBar = this.renderHeader(headerProps); - background = this.renderBackground(headerProps); - } - - const { options } = scene.descriptor; - const { headerStyle = {} } = options; - const headerStyleObj = StyleSheet.flatten(headerStyle) as ViewStyle; - const appBarHeight = getAppBarHeight(isLandscape); - - const { - alignItems, - justifyContent, - flex, - flexDirection, - flexGrow, - flexShrink, - flexBasis, - flexWrap, - position, - padding, - paddingHorizontal, - paddingRight, - paddingLeft, - // paddingVertical, - // paddingTop, - // paddingBottom, - top, - right, - bottom, - left, - ...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'); - warnIfHeaderStyleDefined(padding, 'padding'); - warnIfHeaderStyleDefined(position, 'position'); - warnIfHeaderStyleDefined(paddingHorizontal, 'paddingHorizontal'); - warnIfHeaderStyleDefined(paddingRight, 'paddingRight'); - warnIfHeaderStyleDefined(paddingLeft, 'paddingLeft'); - // warnIfHeaderStyleDefined(paddingVertical, 'paddingVertical'); - // warnIfHeaderStyleDefined(paddingTop, 'paddingTop'); - // warnIfHeaderStyleDefined(paddingBottom, 'paddingBottom'); - warnIfHeaderStyleDefined(top, 'top'); - warnIfHeaderStyleDefined(right, 'right'); - warnIfHeaderStyleDefined(bottom, 'bottom'); - warnIfHeaderStyleDefined(left, 'left'); - } - - // TODO: warn if any unsafe styles are provided - const containerStyles = [ - options.headerTransparent - ? styles.transparentContainer - : styles.container, - { height: appBarHeight }, - safeHeaderStyle, - ]; - - const { headerForceInset } = options; - const forceInset = headerForceInset || { - top: 'always', - bottom: 'never', - horizontal: 'always', - }; - - return ( - - - {background} - {appBar} - - - ); - } -} - -function warnIfHeaderStyleDefined(value: any, styleProp: string) { - if (styleProp === 'position' && value === 'absolute') { - console.warn( - "position: 'absolute' is not supported on headerStyle. If you would like to render content under the header, use the headerTransparent navigationOption." - ); - } else if (value !== undefined) { - console.warn( - `${styleProp} was given a value of ${value}, this has no effect on headerStyle.` - ); - } -} - -const platformContainerStyles = Platform.select({ - android: { - elevation: 4, - }, - ios: { - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#A7A7AA', - }, - default: { - // https://github.com/necolas/react-native-web/issues/44 - // Material Design - boxShadow: `0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12)`, - }, -}); - -const DEFAULT_BACKGROUND_COLOR = '#FFF'; - -const styles = StyleSheet.create({ - container: { - backgroundColor: DEFAULT_BACKGROUND_COLOR, - ...platformContainerStyles, - }, - transparentContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - ...platformContainerStyles, - borderBottomWidth: 0, - borderBottomColor: 'transparent', - elevation: 0, - }, - header: { - ...StyleSheet.absoluteFillObject, - flexDirection: 'row', - }, - item: { - backgroundColor: 'transparent', - }, - iconMaskContainer: { - flex: 1, - flexDirection: 'row', - justifyContent: 'center', - }, - iconMaskFillerRect: { - flex: 1, - backgroundColor: '#d8d8d8', - marginLeft: -5, - }, - iconMask: { - // These are mostly the same as the icon in ModularHeaderBackButton - height: 23, - width: 14.5, - marginLeft: 8.5, - marginTop: -2.5, - alignSelf: 'center', - resizeMode: 'contain', - transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], - }, - // eslint-disable-next-line react-native/no-unused-styles - background: {}, - // eslint-disable-next-line react-native/no-unused-styles - title: { - bottom: 0, - top: 0, - position: 'absolute', - alignItems: 'center', - flexDirection: 'row', - }, - // eslint-disable-next-line react-native/no-unused-styles - left: { - left: 0, - bottom: 0, - top: 0, - position: 'absolute', - alignItems: 'center', - flexDirection: 'row', - }, - // eslint-disable-next-line react-native/no-unused-styles - right: { - right: 0, - bottom: 0, - top: 0, - position: 'absolute', - flexDirection: 'row', - alignItems: 'center', - }, - flexOne: { - flex: 1, - }, -}); - -export default withOrientation(Header); diff --git a/src/views/Header/HeaderAnimated.tsx b/src/views/Header/HeaderAnimated.tsx new file mode 100644 index 000000000..b9382ada0 --- /dev/null +++ b/src/views/Header/HeaderAnimated.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import HeaderBar from './HeaderBar'; +import HeaderSegment, { Scene } from './HeaderSegment'; +import { Route, Layout, HeaderStyleInterpolator } from '../../types'; + +type Props = { + layout: Layout; + onGoBack: (props: { route: T }) => void; + getTitle: (props: { route: T }) => string | undefined; + scenes: Scene[]; + styleInterpolator: HeaderStyleInterpolator; + style?: StyleProp; +}; + +export default function HeaderAnimated({ + scenes, + layout, + onGoBack, + getTitle, + styleInterpolator, +}: Props) { + return ( + + {scenes.map((scene, i, self) => { + const previous = self[i - 1]; + const next = self[i + 1]; + + return ( + onGoBack({ route: scene.route }) : undefined + } + getTitle={getTitle} + styleInterpolator={styleInterpolator} + style={StyleSheet.absoluteFill} + /> + ); + })} + + ); +} diff --git a/src/views/Header/HeaderBackButton.tsx b/src/views/Header/HeaderBackButton.tsx index 5c9d9748d..598405963 100644 --- a/src/views/Header/HeaderBackButton.tsx +++ b/src/views/Header/HeaderBackButton.tsx @@ -2,47 +2,59 @@ import * as React from 'react'; import { I18nManager, Image, - Text, View, Platform, StyleSheet, LayoutChangeEvent, + MaskedViewIOS, } from 'react-native'; - +import Animated from 'react-native-reanimated'; import TouchableItem from '../TouchableItem'; - -import defaultBackImage from '../assets/back-icon.png'; -import BackButtonWeb from './BackButtonWeb'; -import { HeaderBackbuttonProps } from '../../types'; +import { Layout } from '../../types'; + +type Props = { + disabled?: boolean; + onPress: () => void; + pressColorAndroid?: string; + backImage?: (props: { tintColor: string; title?: string }) => React.ReactNode; + tintColor: string; + title?: string; + fallbackTitle?: string; + backTitleVisible?: boolean; + allowFontScaling?: boolean; + titleStyle?: React.ComponentProps['style']; + onTitleLayout?: (e: LayoutChangeEvent) => void; + layout?: Layout; +}; type State = { - initialTextWidth?: number; + initialTitleWidth?: number; }; -class HeaderBackButton extends React.PureComponent< - HeaderBackbuttonProps, - State -> { +class HeaderBackButton extends React.Component { static defaultProps = { pressColorAndroid: 'rgba(0, 0, 0, .32)', tintColor: Platform.select({ ios: '#037aff', web: '#5f6368', }), - truncatedTitle: 'Back', - backImage: Platform.select({ - web: BackButtonWeb, - }), + backTitleVisible: Platform.OS === 'ios', + fallbackTitle: 'Back', }; state: State = {}; - private handleTextLayout = (e: LayoutChangeEvent) => { - if (this.state.initialTextWidth) { + private handleTitleLayout = (e: LayoutChangeEvent) => { + const { onTitleLayout } = this.props; + + onTitleLayout && onTitleLayout(e); + + if (this.state.initialTitleWidth) { return; } + this.setState({ - initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width, + initialTitleWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width, }); }; @@ -51,12 +63,8 @@ class HeaderBackButton extends React.PureComponent< let title = this.getTitleText(); - if (React.isValidElement(backImage)) { - return backImage; - } else if (backImage) { - const BackImage = backImage; - - return ; + if (backImage) { + return backImage({ tintColor, title }); } else { return ( ); @@ -73,16 +81,18 @@ class HeaderBackButton extends React.PureComponent< } private getTitleText = () => { - const { width, title, truncatedTitle } = this.props; - - let { initialTextWidth } = this.state; - - if (title === null) { - return null; - } else if (!title) { - return truncatedTitle; - } else if (initialTextWidth && width && initialTextWidth > width) { - return truncatedTitle; + const { layout, title, fallbackTitle } = this.props; + + let { initialTitleWidth } = this.state; + + if (!title) { + return fallbackTitle; + } else if ( + initialTitleWidth && + layout && + initialTitleWidth > layout.width / 4 + ) { + return fallbackTitle; } else { return title; } @@ -92,32 +102,60 @@ class HeaderBackButton extends React.PureComponent< const { allowFontScaling, backTitleVisible, + backImage, titleStyle, tintColor, + layout, } = this.props; + let backTitleText = this.getTitleText(); - if (!backTitleVisible || backTitleText === null) { + if (!backTitleVisible || backTitleText === undefined) { return null; } - return ( - {this.getTitleText()} - + + ); + + if (backImage) { + return title; + } + + return ( + + + + + } + > + {title} + ); } render() { const { onPress, pressColorAndroid, title, disabled } = this.props; - let button = ( + return ( - + {this.renderBackImage()} {this.maybeRenderTitle()} - + ); - - if (Platform.OS === 'ios') { - return button; - } else { - return {button}; - } } } const styles = StyleSheet.create({ - disabled: { - opacity: 0.5, - }, - androidButtonWrapper: { - margin: 13, - backgroundColor: 'transparent', + container: { + alignItems: 'center', + flexDirection: 'row', ...Platform.select({ - web: { - marginLeft: 21, + ios: null, + default: { + marginVertical: 3, + marginHorizontal: 11, }, - default: {}, }), }, - container: { - alignItems: 'center', - flexDirection: 'row', - backgroundColor: 'transparent', + disabled: { + opacity: 0.5, }, title: { fontSize: 17, - paddingRight: 10, }, icon: Platform.select({ ios: { - backgroundColor: 'transparent', height: 21, width: 13, - marginLeft: 9, + marginLeft: 8, marginRight: 22, marginVertical: 12, resizeMode: 'contain', @@ -186,7 +216,6 @@ const styles = StyleSheet.create({ width: 24, margin: 3, resizeMode: 'contain', - backgroundColor: 'transparent', transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], }, }), @@ -196,6 +225,24 @@ const styles = StyleSheet.create({ marginRight: 6, } : {}, + iconMaskContainer: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + iconMaskFillerRect: { + flex: 1, + backgroundColor: '#000', + }, + iconMask: { + height: 21, + width: 13, + marginLeft: -14.5, + marginVertical: 12, + alignSelf: 'center', + resizeMode: 'contain', + transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], + }, }); export default HeaderBackButton; diff --git a/src/views/Header/HeaderBar.tsx b/src/views/Header/HeaderBar.tsx new file mode 100644 index 000000000..e1a1eddb6 --- /dev/null +++ b/src/views/Header/HeaderBar.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import { SafeAreaView } from '@react-navigation/native'; +import { Layout } from '../../types'; + +type Props = React.ComponentProps & { + children: React.ReactNode; + layout: Layout; +}; + +const getHeaderHeight = (layout: Layout) => { + const isLandscape = layout.width > layout.height; + + if (Platform.OS === 'ios') { + // @ts-ignore + if (isLandscape && !Platform.isPad) { + return 32; + } else { + return 44; + } + } else if (Platform.OS === 'android') { + return 56; + } else { + return 64; + } +}; + +export default function HeaderBar({ + forceInset = { + top: 'always', + left: 'never', + bottom: 'never', + right: 'never', + }, + layout, + style, + children, + ...rest +}: Props) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: 'white', + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#A7A7AA', + }, + default: { + // https://github.com/necolas/react-native-web/issues/44 + // Material Design + boxShadow: `0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12)`, + }, + }), + }, +}); diff --git a/src/views/Header/HeaderSegment.tsx b/src/views/Header/HeaderSegment.tsx new file mode 100644 index 000000000..7a81cd0bf --- /dev/null +++ b/src/views/Header/HeaderSegment.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { + View, + StyleSheet, + StyleProp, + ViewStyle, + LayoutChangeEvent, + Platform, +} from 'react-native'; +import Animated from 'react-native-reanimated'; +import HeaderTitle from './HeaderTitle'; +import HeaderBackButton from './HeaderBackButton'; +import memoize from '../../utils/memoize'; +import { Route, Layout, HeaderStyleInterpolator } from '../../types'; + +export type Scene = { + route: T; + progress: Animated.Node; +}; + +type Props = { + layout: Layout; + onGoBack?: () => void; + getTitle: (props: { route: T }) => string | undefined; + scene: Scene; + previous?: Scene; + next?: Scene; + styleInterpolator: HeaderStyleInterpolator; + style?: StyleProp; +}; + +type State = { + titleLayout?: Layout; + backTitleLayout?: Layout; +}; + +export default class HeaderSegment extends React.Component< + Props, + State +> { + state: State = {}; + + private handleTitleLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + + this.setState({ titleLayout: { height, width } }); + }; + + private handleBackTitleLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + + this.setState({ backTitleLayout: { height, width } }); + }; + + private getInterpolatedStyle = memoize( + ( + styleInterpolator: HeaderStyleInterpolator, + layout: Layout, + current: Animated.Node, + next: Animated.Node | undefined, + titleLayout: Layout | undefined, + backTitleLayout: Layout | undefined + ) => + styleInterpolator({ + positions: { + current, + next, + }, + layouts: { + screen: layout, + title: titleLayout, + backTitle: backTitleLayout, + }, + }) + ); + + render() { + const { + scene, + previous, + next, + layout, + onGoBack, + getTitle, + styleInterpolator, + style, + } = this.props; + + const { backTitleLayout, titleLayout } = this.state; + const currentTitle = getTitle({ route: scene.route }); + const previousTitle = previous + ? getTitle({ route: previous.route }) + : undefined; + + const { + titleStyle, + leftButtonStyle, + backTitleStyle, + } = this.getInterpolatedStyle( + styleInterpolator, + layout, + scene.progress, + next ? next.progress : undefined, + titleLayout, + previousTitle ? backTitleLayout : undefined + ); + + return ( + + {onGoBack ? ( + + + + ) : null} + {currentTitle ? ( + + {currentTitle} + + ) : null} + + ); + } +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 4, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + left: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + }, + title: Platform.select({ + ios: {}, + default: { position: 'absolute' }, + }), +}); diff --git a/src/views/Header/HeaderSimple.tsx b/src/views/Header/HeaderSimple.tsx new file mode 100644 index 000000000..cdd99656d --- /dev/null +++ b/src/views/Header/HeaderSimple.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import HeaderBar from './HeaderBar'; +import { Route, HeaderStyleInterpolator, Layout } from '../../types'; +import HeaderSegment, { Scene } from './HeaderSegment'; + +type Props = { + layout: Layout; + onGoBack?: () => void; + getTitle: (props: { route: T }) => string | undefined; + scene: Scene; + previous?: Scene; + next?: Scene; + styleInterpolator: HeaderStyleInterpolator; + style?: StyleProp; +}; + +export default function HeaderSimple({ + layout, + style, + ...rest +}: Props) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/src/views/Header/HeaderStyleInterpolator.tsx b/src/views/Header/HeaderStyleInterpolator.tsx deleted file mode 100644 index f7d541220..000000000 --- a/src/views/Header/HeaderStyleInterpolator.tsx +++ /dev/null @@ -1,406 +0,0 @@ -import { Dimensions, I18nManager } from 'react-native'; -import getSceneIndicesForInterpolationInputRange from '../../utils/getSceneIndicesForInterpolationInputRange'; -import { Scene, SceneInterpolatorProps } from '../../types'; - -function hasHeader(scene: Scene) { - if (!scene) { - return true; - } - const { descriptor } = scene; - return descriptor.options.header !== null; -} - -const crossFadeInterpolation = ( - scenes: Scene[], - first: number, - index: number, - last: number -): { inputRange: number[]; outputRange: number[]; extrapolate: 'clamp' } => ({ - inputRange: [ - first, - first + 0.001, - index - 0.9, - index - 0.2, - index, - last - 0.001, - last, - ], - outputRange: [ - 0, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[first]) ? 0.3 : 1, - hasHeader(scenes[index]) ? 1 : 0, - hasHeader(scenes[last]) ? 0 : 1, - 0, - ], - extrapolate: 'clamp', -}); - -/** - * Utilities that build the style for the navigation header. - * - * +-------------+-------------+-------------+ - * | | | | - * | Left | Title | Right | - * | Component | Component | Component | - * | | | | - * +-------------+-------------+-------------+ - */ - -function isGoingBack(scenes: Scene[]) { - const lastSceneIndexInScenes = scenes.length - 1; - return !scenes[lastSceneIndexInScenes].isActive; -} - -function forLayout(props: SceneInterpolatorProps) { - const { layout, position, scene, scenes, mode } = props; - if (mode !== 'float') { - return {}; - } - const isBack = isGoingBack(scenes); - - const interpolate = getSceneIndicesForInterpolationInputRange(props); - if (!interpolate) return {}; - - const { first, last } = interpolate; - const index = scene.index; - - // We really shouldn't render the scene at all until we know the width of the - // stack. That said, in every case that I have ever seen, this has just been - // the full width of the window. This won't continue to be true if we support - // layouts like iPad master-detail. For now, in order to solve - // https://github.com/react-navigation/react-navigation/issues/4264, I have - // opted for the heuristic that we will use the window width until we have - // measured (and they will usually be the same). - const width = layout.initWidth || Dimensions.get('window').width; - - // Make sure the header stays hidden when transitioning between 2 screens - // with no header. - if ( - (isBack && !hasHeader(scenes[index]) && !hasHeader(scenes[last])) || - (!isBack && !hasHeader(scenes[first]) && !hasHeader(scenes[index])) - ) { - return { - transform: [{ translateX: width }], - }; - } - - const rtlMult = I18nManager.isRTL ? -1 : 1; - const translateX = position.interpolate({ - inputRange: [first, index, last], - outputRange: [ - rtlMult * (hasHeader(scenes[first]) ? 0 : width), - rtlMult * (hasHeader(scenes[index]) ? 0 : isBack ? width : -width), - rtlMult * (hasHeader(scenes[last]) ? 0 : -width), - ], - extrapolate: 'clamp', - }); - - return { - transform: [{ translateX }], - }; -} - -function forLeft(props: SceneInterpolatorProps) { - 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( - crossFadeInterpolation(scenes, first, index, last) - ), - }; -} - -function forCenter(props: SceneInterpolatorProps) { - 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( - crossFadeInterpolation(scenes, first, index, last) - ), - }; -} - -function forRight(props: SceneInterpolatorProps) { - 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( - crossFadeInterpolation(scenes, first, index, last) - ), - }; -} - -/** - * iOS UINavigationController style interpolators - */ - -function forLeftButton(props: SceneInterpolatorProps) { - const { position, scene, scenes } = props; - const interpolate = getSceneIndicesForInterpolationInputRange(props); - - if (!interpolate) return { opacity: 0 }; - - const { first, last } = interpolate; - const index = scene.index; - - // The gist of what we're doing here is animating the left button _normally_ (fast fade) - // when both scenes in transition have headers. When the current, next, or previous scene _don't_ - // have a header, we don't fade the button, and only set it's opacity to 0 at the last moment - // of the transition. - const inputRange = [ - first, - first + 0.001, - first + Math.abs(index - first) / 2, - index, - last - Math.abs(last - index) / 2, - last - 0.001, - last, - ]; - const outputRange = [ - 0, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[first]) ? 0.3 : 1, - hasHeader(scenes[index]) ? 1 : 0, - hasHeader(scenes[last]) ? 0.3 : 1, - hasHeader(scenes[last]) ? 0 : 1, - 0, - ]; - - return { - opacity: position.interpolate({ - inputRange, - outputRange, - extrapolate: 'clamp', - }), - }; -} - -/* - * NOTE: this offset calculation is 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: SceneInterpolatorProps) { - 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; - - // Similarly to the animation of the left label, when animating to or from a scene without - // a header, we keep the label at full opacity and in the same position for as long as possible. - 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, - first + 0.001, - index - 0.35, - index, - index + 0.5, - last - 0.001, - last, - ], - outputRange: [ - 0, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[index]) ? 1 : 0, - hasHeader(scenes[last]) ? 0.5 : 1, - hasHeader(scenes[last]) ? 0 : 1, - 0, - ], - extrapolate: 'clamp', - }), - transform: [ - { - translateX: position.interpolate({ - inputRange: [first, first + 0.001, index, last - 0.001, last], - outputRange: I18nManager.isRTL - ? [ - -offset * 1.5, - hasHeader(scenes[first]) ? -offset * 1.5 : 0, - 0, - hasHeader(scenes[last]) ? offset : 0, - offset, - ] - : [ - offset, - hasHeader(scenes[first]) ? offset : 0, - 0, - hasHeader(scenes[last]) ? -offset * 1.5 : 0, - -offset * 1.5, - ], - extrapolate: 'clamp', - }), - }, - ], - }; -} - -/* - * 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: SceneInterpolatorProps) { - const { position, scene, scenes } = props; - const interpolate = getSceneIndicesForInterpolationInputRange(props); - - if (!interpolate) return { opacity: 0 }; - - const { first, last } = interpolate; - const index = scene.index; - const offset = TITLE_OFFSET_IOS; - - return { - opacity: position.interpolate({ - inputRange: [ - first, - first + 0.001, - index - 0.5, - index, - index + 0.7, - last - 0.001, - last, - ], - outputRange: [ - 0, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[first]) ? 0 : 1, - hasHeader(scenes[index]) ? 1 : 0, - hasHeader(scenes[last]) ? 0 : 1, - hasHeader(scenes[last]) ? 0 : 1, - 0, - ], - extrapolate: 'clamp', - }), - transform: [ - { - translateX: position.interpolate({ - inputRange: [first, first + 0.001, index, last - 0.001, last], - outputRange: I18nManager.isRTL - ? [ - -offset, - hasHeader(scenes[first]) ? -offset : 0, - 0, - hasHeader(scenes[last]) ? offset : 0, - offset, - ] - : [ - offset, - hasHeader(scenes[first]) ? offset : 0, - 0, - hasHeader(scenes[last]) ? -offset : 0, - -offset, - ], - extrapolate: 'clamp', - }), - }, - ], - }; -} - -// Fade in background of header while transitioning -function forBackgroundWithFade(props: SceneInterpolatorProps) { - const { position, scene } = props; - const sceneRange = getSceneIndicesForInterpolationInputRange(props); - if (!sceneRange) return { opacity: 0 }; - return { - opacity: position.interpolate({ - inputRange: [sceneRange.first, scene.index, sceneRange.last], - outputRange: [0, 1, 0], - extrapolate: 'clamp', - }), - }; -} - -const VISIBLE = { opacity: 1 }; -const HIDDEN = { opacity: 0 }; - -// Toggle visibility of header without fading -function forBackgroundWithInactiveHidden({ - navigation, - scene, -}: SceneInterpolatorProps) { - return navigation.state.index === scene.index ? VISIBLE : HIDDEN; -} - -// Translate the background with the card -const BACKGROUND_OFFSET = Dimensions.get('window').width; - -function forBackgroundWithTranslation(props: SceneInterpolatorProps) { - const { position, scene } = props; - const interpolate = getSceneIndicesForInterpolationInputRange(props); - if (!interpolate) return { opacity: 0 }; - const { first, last } = interpolate; - const index = scene.index; - const offset = BACKGROUND_OFFSET; - const outputRange = [offset, 0, -offset]; - return { - transform: [ - { - translateX: position.interpolate({ - inputRange: [first, index, last], - outputRange: I18nManager.isRTL ? outputRange.reverse() : outputRange, - extrapolate: 'clamp', - }), - }, - ], - }; -} - -// Default to fade transition -const forBackground = forBackgroundWithInactiveHidden; - -export default { - forLayout, - forLeft, - forLeftButton, - forLeftLabel, - forCenterFromLeft, - forCenter, - forRight, - forBackground, - forBackgroundWithInactiveHidden, - forBackgroundWithFade, - forBackgroundWithTranslation, -}; diff --git a/src/views/Header/HeaderTitle.tsx b/src/views/Header/HeaderTitle.tsx index e3c95e24f..826dcd14a 100644 --- a/src/views/Header/HeaderTitle.tsx +++ b/src/views/Header/HeaderTitle.tsx @@ -1,40 +1,31 @@ import * as React from 'react'; -import { Platform, StyleSheet, Animated } from 'react-native'; +import { StyleSheet, Platform } from 'react-native'; +import Animated from 'react-native-reanimated'; -const HeaderTitle = ({ - style, - ...rest -}: React.ComponentProps) => ( - -); +type Props = React.ComponentProps & { + children: string; +}; + +export default function HeaderTitle({ style, ...rest }: Props) { + return ; +} const styles = StyleSheet.create({ - title: { - ...Platform.select({ - ios: { - fontSize: 17, - fontWeight: '600', - color: 'rgba(0, 0, 0, .9)', - marginHorizontal: 16, - }, - android: { - fontSize: 20, - fontWeight: '500', - color: 'rgba(0, 0, 0, .9)', - marginHorizontal: 16, - }, - default: { - fontSize: 18, - fontWeight: '400', - color: '#3c4043', - }, - }), - }, + title: Platform.select({ + ios: { + fontSize: 17, + fontWeight: '600', + color: 'rgba(0, 0, 0, .9)', + }, + android: { + fontSize: 20, + fontWeight: '500', + color: 'rgba(0, 0, 0, .9)', + }, + default: { + fontSize: 18, + fontWeight: '400', + color: '#3c4043', + }, + }), }); - -export default HeaderTitle; diff --git a/src/views/Header/ModularHeaderBackButton.tsx b/src/views/Header/ModularHeaderBackButton.tsx deleted file mode 100644 index 89d4f9215..000000000 --- a/src/views/Header/ModularHeaderBackButton.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import * as React from 'react'; -import { - I18nManager, - Image, - Text, - View, - StyleSheet, - LayoutChangeEvent, -} from 'react-native'; - -import TouchableItem from '../TouchableItem'; - -import defaultBackImage from '../assets/back-icon.png'; -import { HeaderBackbuttonProps } from '../../types'; - -type Props = HeaderBackbuttonProps & { - LabelContainerComponent: React.ComponentType; - ButtonContainerComponent: React.ComponentType; -}; - -type State = { - initialTextWidth?: number; -}; - -class ModularHeaderBackButton extends React.PureComponent { - static defaultProps = { - tintColor: '#037aff', - truncatedTitle: 'Back', - }; - - state: State = {}; - - private onTextLayout = (e: LayoutChangeEvent) => { - if (this.state.initialTextWidth) { - return; - } - this.setState({ - initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width, - }); - }; - - private renderBackImage() { - const { backImage, backTitleVisible, tintColor } = this.props; - - if (React.isValidElement(backImage)) { - return backImage; - } else if (backImage) { - const BackImage = backImage; - - return ; - } else { - return ( - - ); - } - } - - private getTitleText = () => { - const { width, title, truncatedTitle } = this.props; - - let { initialTextWidth } = this.state; - - if (title === null) { - return null; - } else if (!title) { - return truncatedTitle; - } else if (initialTextWidth && width && initialTextWidth > width) { - return truncatedTitle; - } else { - return title.length > 8 ? truncatedTitle : title; - } - }; - - private maybeRenderTitle() { - const { backTitleVisible, titleStyle, tintColor } = this.props; - let backTitleText = this.getTitleText(); - - if (!backTitleVisible || backTitleText === null) { - return null; - } - - const { LabelContainerComponent } = this.props; - - return ( - - - {this.getTitleText()} - - - ); - } - - render() { - const { onPress, title } = this.props; - const { ButtonContainerComponent } = this.props; - - return ( - - - - {this.renderBackImage()} - - {this.maybeRenderTitle()} - - - ); - } -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - backgroundColor: 'transparent', - marginBottom: 1, - overflow: 'visible', - }, - title: { - fontSize: 17, - paddingRight: 10, - }, - icon: { - height: 21, - width: 12, - marginLeft: 9, - marginRight: 22, - marginVertical: 12, - resizeMode: 'contain', - transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], - }, - iconWithTitle: { - marginRight: 3, - }, -}); - -export default ModularHeaderBackButton; diff --git a/src/views/ScenesReducer.tsx b/src/views/ScenesReducer.tsx deleted file mode 100644 index 4609975f8..000000000 --- a/src/views/ScenesReducer.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import shallowEqual from '../utils/shallowEqual'; -import { Scene, Route, NavigationState, SceneDescriptor } from '../types'; - -const SCENE_KEY_PREFIX = 'scene_'; - -/** - * Helper function to compare route keys (e.g. "9", "11"). - */ -function compareKey(one: string, two: string) { - const delta = one.length - two.length; - if (delta > 0) { - return 1; - } - if (delta < 0) { - return -1; - } - return one > two ? 1 : -1; -} - -/** - * Helper function to sort scenes based on their index and view key. - */ -function compareScenes(one: Scene, two: Scene) { - if (one.index > two.index) { - return 1; - } - if (one.index < two.index) { - return -1; - } - - return compareKey(one.key, two.key); -} - -/** - * Whether two routes are the same. - */ -function areScenesShallowEqual(one: Scene, two: Scene) { - return ( - one.key === two.key && - one.index === two.index && - one.isStale === two.isStale && - one.isActive === two.isActive && - areRoutesShallowEqual(one.route, two.route) - ); -} - -/** - * Whether two routes are the same. - */ -function areRoutesShallowEqual(one: Route, two: Route) { - if (!one || !two) { - return one === two; - } - - if (one.key !== two.key) { - return false; - } - - return shallowEqual(one, two); -} - -export default function ScenesReducer( - scenes: Scene[], - nextState: NavigationState, - prevState: NavigationState | null, - descriptors: { [key: string]: SceneDescriptor } -) { - // Always update the descriptors - // This is a workaround for https://github.com/react-navigation/react-navigation/issues/4271 - // It will be resolved in a better way when we re-write Transitioner - scenes.forEach(scene => { - const { route } = scene; - if (descriptors && descriptors[route.key]) { - scene.descriptor = descriptors[route.key]; - } - }); - - // Bail out early if we didn't update the state - if (prevState === nextState) { - return scenes; - } - - const prevScenes = new Map(); - const freshScenes = new Map(); - const staleScenes = new Map(); - - // Populate stale scenes from previous scenes marked as stale. - scenes.forEach(scene => { - const { key } = scene; - if (scene.isStale) { - staleScenes.set(key, scene); - } - prevScenes.set(key, scene); - }); - - const nextKeys = new Set(); - let nextRoutes = nextState.routes; - if (nextRoutes.length > nextState.index + 1) { - console.warn( - 'StackRouter provided invalid state, index should always be the top route' - ); - nextRoutes = nextState.routes.slice(0, nextState.index + 1); - } - - nextRoutes.forEach((route, index) => { - const key = SCENE_KEY_PREFIX + route.key; - - let descriptor = descriptors && descriptors[route.key]; - - const scene: Scene = { - index, - isActive: false, - isStale: false, - key, - route, - descriptor, - }; - - if (nextKeys.has(key)) { - throw new Error( - `navigation.state.routes[${index}].key "${key}" conflicts with ` + - 'another route!' - ); - } - - nextKeys.add(key); - - if (staleScenes.has(key)) { - // A previously `stale` scene is now part of the nextState, so we - // revive it by removing it from the stale scene map. - staleScenes.delete(key); - } - freshScenes.set(key, scene); - }); - - if (prevState) { - let prevRoutes = prevState.routes; - if (prevRoutes.length > prevState.index + 1) { - console.warn( - 'StackRouter provided invalid state, index should always be the top route' - ); - prevRoutes = prevRoutes.slice(0, prevState.index + 1); - } - // Look at the previous routes and classify any removed scenes as `stale`. - prevRoutes.forEach((route, index) => { - const key = SCENE_KEY_PREFIX + route.key; - if (freshScenes.has(key)) { - return; - } - const lastScene = scenes.find(scene => scene.route.key === route.key); - - // We can get into a weird place where we have a queued transition and then clobber - // that transition without ever actually rendering the scene, in which case - // there is no lastScene. If the descriptor is not available on the lastScene - // or the descriptors prop then we just skip adding it to stale scenes and it's - // not ever rendered. - const descriptor = lastScene - ? lastScene.descriptor - : descriptors[route.key]; - - if (descriptor) { - staleScenes.set(key, { - index, - isActive: false, - isStale: true, - key, - route, - descriptor, - }); - } - }); - } - - const nextScenes: Scene[] = []; - - const mergeScene = (nextScene: Scene) => { - const { key } = nextScene; - const prevScene = prevScenes.has(key) ? prevScenes.get(key) : null; - if (prevScene && areScenesShallowEqual(prevScene, nextScene)) { - // Reuse `prevScene` as `scene` so view can avoid unnecessary re-render. - // This assumes that the scene's navigation state is immutable. - nextScenes.push(prevScene); - } else { - nextScenes.push(nextScene); - } - }; - - staleScenes.forEach(mergeScene); - freshScenes.forEach(mergeScene); - - nextScenes.sort(compareScenes); - - let activeScenesCount = 0; - nextScenes.forEach((scene, ii) => { - const isActive = !scene.isStale && scene.index === nextState.index; - if (isActive !== scene.isActive) { - nextScenes[ii] = { - ...scene, - isActive, - }; - } - if (isActive) { - activeScenesCount++; - } - }); - - if (activeScenesCount !== 1) { - throw new Error( - `There should always be only one scene active, not ${activeScenesCount}.` - ); - } - - if (nextScenes.length !== scenes.length) { - return nextScenes; - } - - if ( - nextScenes.some( - (scene, index) => !areScenesShallowEqual(scenes[index], scene) - ) - ) { - return nextScenes; - } - - // scenes haven't changed. - return scenes; -} diff --git a/src/views/Stack/Card.tsx b/src/views/Stack/Card.tsx new file mode 100755 index 000000000..3efde310a --- /dev/null +++ b/src/views/Stack/Card.tsx @@ -0,0 +1,415 @@ +import * as React from 'react'; +import { View, StyleSheet, ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { + PanGestureHandler, + State as GestureState, +} from 'react-native-gesture-handler'; +import { TransitionSpec, CardStyleInterpolator } from '../../types'; +import memoize from '../../utils/memoize'; +import StackGestureContext from '../../utils/StackGestureContext'; + +type Props = ViewProps & { + closing?: boolean; + next?: Animated.Node; + current: Animated.Value; + layout: { width: number; height: number }; + direction: 'horizontal' | 'vertical'; + onOpen?: () => void; + onClose?: () => void; + children: React.ReactNode; + animateIn: boolean; + gesturesEnabled: boolean; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; + }; + styleInterpolator: CardStyleInterpolator; +}; + +type Binary = 0 | 1; + +const TRUE = 1; +const FALSE = 0; +const NOOP = 0; +const UNSET = -1; + +const DIRECTION_VERTICAL = -1; +const DIRECTION_HORIZONTAL = 1; + +const SWIPE_VELOCITY_THRESHOLD_DEFAULT = 500; +const SWIPE_DISTANCE_THRESHOLD_DEFAULT = 60; + +const SWIPE_DISTANCE_MINIMUM = 5; + +const { + cond, + eq, + neq, + set, + and, + or, + greaterThan, + lessThan, + abs, + add, + max, + block, + stopClock, + startClock, + clockRunning, + onChange, + Value, + Clock, + call, + spring, + timing, + interpolate, +} = Animated; + +export default class Card extends React.Component { + static defaultProps = { + animateIn: true, + gesturesEnabled: true, + }; + + componentDidUpdate(prevProps: Props) { + const { layout, direction, closing, animateIn } = this.props; + const { width, height } = layout; + + if ( + width !== prevProps.layout.width || + height !== prevProps.layout.height + ) { + this.layout.width.setValue(width); + this.layout.height.setValue(height); + + this.position.setValue( + animateIn + ? direction === 'vertical' + ? layout.height + : layout.width + : 0 + ); + } + + if (direction !== prevProps.direction) { + this.direction.setValue( + direction === 'vertical' ? DIRECTION_VERTICAL : DIRECTION_HORIZONTAL + ); + } + + if (closing !== prevProps.closing) { + this.isClosing.setValue(closing ? TRUE : FALSE); + } + } + + private isVisible = new Value(TRUE); + private nextIsVisible = new Value(UNSET); + + private isClosing = new Value(FALSE); + + private clock = new Clock(); + + private direction = new Value( + this.props.direction === 'vertical' + ? DIRECTION_VERTICAL + : DIRECTION_HORIZONTAL + ); + + private layout = { + width: new Value(this.props.layout.width), + height: new Value(this.props.layout.height), + }; + + private distance = cond( + eq(this.direction, DIRECTION_VERTICAL), + this.layout.height, + this.layout.width + ); + + private position = new Value( + this.props.animateIn + ? this.props.direction === 'vertical' + ? this.props.layout.height + : this.props.layout.width + : 0 + ); + + private gesture = new Value(0); + private offset = new Value(0); + private velocity = new Value(0); + + private gestureState = new Value(0); + + private isSwiping = new Value(FALSE); + private isSwipeGesture = new Value(FALSE); + + private toValue = new Value(0); + private frameTime = new Value(0); + + private transitionState = { + position: this.position, + time: new Value(0), + finished: new Value(FALSE), + }; + + private runTransition = (isVisible: Binary | Animated.Node) => { + const { open: openingSpec, close: closingSpec } = this.props.transitionSpec; + + const toValue = cond(isVisible, 0, this.distance); + + return cond(eq(this.position, toValue), NOOP, [ + cond(clockRunning(this.clock), NOOP, [ + // Animation wasn't running before + // Set the initial values and start the clock + set(this.toValue, toValue), + set(this.frameTime, 0), + set(this.transitionState.time, 0), + set(this.transitionState.finished, FALSE), + set(this.isVisible, isVisible), + startClock(this.clock), + ]), + cond( + eq(toValue, 0), + openingSpec.timing === 'spring' + ? spring( + this.clock, + { ...this.transitionState, velocity: this.velocity }, + { ...openingSpec.config, toValue: this.toValue } + ) + : timing( + this.clock, + { ...this.transitionState, frameTime: this.frameTime }, + { ...openingSpec.config, toValue: this.toValue } + ), + closingSpec.timing === 'spring' + ? spring( + this.clock, + { ...this.transitionState, velocity: this.velocity }, + { ...closingSpec.config, toValue: this.toValue } + ) + : timing( + this.clock, + { ...this.transitionState, frameTime: this.frameTime }, + { ...closingSpec.config, toValue: this.toValue } + ) + ), + cond(this.transitionState.finished, [ + // Reset values + set(this.isSwipeGesture, FALSE), + set(this.gesture, 0), + set(this.velocity, 0), + // When the animation finishes, stop the clock + stopClock(this.clock), + call([this.isVisible], ([value]: ReadonlyArray) => { + const isOpen = Boolean(value); + const { onOpen, onClose } = this.props; + + if (isOpen) { + onOpen && onOpen(); + } else { + onClose && onClose(); + } + }), + ]), + ]); + }; + + private translate = block([ + onChange( + this.isClosing, + cond( + this.isClosing, + set(this.nextIsVisible, FALSE), + set(this.nextIsVisible, TRUE) + ) + ), + onChange( + this.nextIsVisible, + cond(neq(this.nextIsVisible, UNSET), [ + // Stop any running animations + cond(clockRunning(this.clock), stopClock(this.clock)), + set(this.gesture, 0), + // Update the index to trigger the transition + set(this.isVisible, this.nextIsVisible), + set(this.nextIsVisible, UNSET), + ]) + ), + // Synchronize the translation with the animated value representing the progress + set( + this.props.current, + cond( + or(eq(this.layout.width, 0), eq(this.layout.height, 0)), + this.isVisible, + interpolate(this.position, { + inputRange: [0, this.distance], + outputRange: [1, 0], + }) + ) + ), + cond( + eq(this.gestureState, GestureState.ACTIVE), + [ + cond(this.isSwiping, NOOP, [ + // We weren't dragging before, set it to true + set(this.isSwiping, TRUE), + set(this.isSwipeGesture, TRUE), + // Also update the drag offset to the last position + set(this.offset, this.position), + ]), + // Update position with next offset + gesture distance + set(this.position, max(add(this.offset, this.gesture), 0)), + // Stop animations while we're dragging + stopClock(this.clock), + ], + [ + set(this.isSwiping, FALSE), + this.runTransition( + cond( + or( + and( + greaterThan(abs(this.gesture), SWIPE_DISTANCE_MINIMUM), + greaterThan( + abs(this.velocity), + SWIPE_VELOCITY_THRESHOLD_DEFAULT + ) + ), + cond( + greaterThan( + abs(this.gesture), + SWIPE_DISTANCE_THRESHOLD_DEFAULT + ), + TRUE, + FALSE + ) + ), + cond( + lessThan( + cond(eq(this.velocity, 0), this.gesture, this.velocity), + 0 + ), + TRUE, + FALSE + ), + this.isVisible + ) + ), + ] + ), + this.position, + ]); + + private handleGestureEventHorizontal = Animated.event([ + { + nativeEvent: { + translationX: this.gesture, + velocityX: this.velocity, + state: this.gestureState, + }, + }, + ]); + + private handleGestureEventVertical = Animated.event([ + { + nativeEvent: { + translationY: this.gesture, + velocityY: this.velocity, + state: this.gestureState, + }, + }, + ]); + + // We need to ensure that this style doesn't change unless absolutely needs to + // Changing it too often will result in huge frame drops due to detaching and attaching + // Changing it during an animations can result in unexpected results + private getInterpolatedStyle = memoize( + ( + styleInterpolator: CardStyleInterpolator, + current: Animated.Node, + next: Animated.Node | undefined + ) => + styleInterpolator({ + positions: { + current, + next, + }, + closing: this.isClosing, + layout: this.layout, + }) + ); + + private gestureRef: React.Ref = React.createRef(); + + render() { + const { + layout, + current, + next, + direction, + gesturesEnabled, + children, + styleInterpolator, + ...rest + } = this.props; + + const { + containerStyle, + cardStyle, + overlayStyle, + } = this.getInterpolatedStyle(styleInterpolator, current, next); + + const handleGestureEvent = + direction === 'vertical' + ? this.handleGestureEventVertical + : this.handleGestureEventHorizontal; + + return ( + + + + {overlayStyle ? ( + + ) : null} + + + + {children} + + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + }, + card: { + ...StyleSheet.absoluteFillObject, + shadowOffset: { width: -1, height: 1 }, + shadowRadius: 5, + shadowColor: '#000', + backgroundColor: 'white', + elevation: 2, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#000', + }, +}); diff --git a/src/views/Stack/Stack.tsx b/src/views/Stack/Stack.tsx new file mode 100755 index 000000000..30d72d374 --- /dev/null +++ b/src/views/Stack/Stack.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import { View, StyleSheet, LayoutChangeEvent } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Card from './Card'; +import HeaderAnimated from '../Header/HeaderAnimated'; +import { + Route, + Layout, + TransitionSpec, + CardStyleInterpolator, + HeaderStyleInterpolator, + HeaderMode, + GestureDirection, +} from '../../types'; +import HeaderSimple from '../Header/HeaderSimple'; + +type ProgressValues = { + [key: string]: Animated.Value; +}; + +type Props = { + routes: T[]; + openingRoutes: string[]; + closingRoutes: string[]; + onGoBack: (props: { route: T }) => void; + onOpenRoute: (props: { route: T }) => void; + onCloseRoute: (props: { route: T }) => void; + getTitle: (props: { route: T }) => string | undefined; + renderScene: (props: { route: T }) => React.ReactNode; + headerMode: HeaderMode; + direction: GestureDirection; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; + }; + cardStyleInterpolator: CardStyleInterpolator; + headerStyleInterpolator: HeaderStyleInterpolator; +}; + +type State = { + routes: T[]; + progress: ProgressValues; + layout: Layout; +}; + +export default class Stack extends React.Component< + Props, + State +> { + static getDerivedStateFromProps(props: Props, state: State) { + if (props.routes === state.routes) { + return null; + } + + return { + progress: props.routes.reduce( + (acc, curr) => { + acc[curr.key] = state.progress[curr.key] || new Animated.Value(0); + + return acc; + }, + {} as ProgressValues + ), + routes: props.routes, + }; + } + + state: State = { + routes: [], + progress: {}, + layout: { width: 0, height: 0 }, + }; + + private handleLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + + this.setState({ layout: { width, height } }); + }; + + render() { + const { + routes, + openingRoutes, + closingRoutes, + onGoBack, + onOpenRoute, + onCloseRoute, + getTitle, + renderScene, + headerMode, + direction, + transitionSpec, + cardStyleInterpolator, + headerStyleInterpolator, + } = this.props; + const { layout, progress } = this.state; + + return ( + + {headerMode === 'float' ? ( + ({ + route, + progress: progress[route.key], + }))} + onGoBack={onGoBack} + getTitle={getTitle} + styleInterpolator={headerStyleInterpolator} + /> + ) : null} + + {routes.map((route, index, self) => { + const focused = index === self.length - 1; + const current = progress[route.key]; + const next = self[index + 1] + ? progress[self[index + 1].key] + : undefined; + + return ( + onOpenRoute({ route })} + onClose={() => onCloseRoute({ route })} + animateIn={openingRoutes.includes(route.key)} + gesturesEnabled={index !== 0} + transitionSpec={transitionSpec} + styleInterpolator={cardStyleInterpolator} + accessibilityElementsHidden={!focused} + importantForAccessibility={ + focused ? 'auto' : 'no-hide-descendants' + } + pointerEvents="box-none" + style={StyleSheet.absoluteFill} + > + {headerMode === 'screen' ? ( + onGoBack({ route }) : undefined + } + styleInterpolator={headerStyleInterpolator} + /> + ) : null} + {renderScene({ route })} + + ); + })} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + }, +}); diff --git a/src/views/Stack/StackView.tsx b/src/views/Stack/StackView.tsx new file mode 100644 index 000000000..fac784875 --- /dev/null +++ b/src/views/Stack/StackView.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { SceneView, StackActions } from '@react-navigation/core'; +import Stack from './Stack'; +import { + DefaultTransition, + ModalSlideFromBottomIOS, +} from '../../TransitionConfigs/TransitionPresets'; +import { + NavigationProp, + SceneDescriptor, + NavigationConfig, + Route, +} from '../../types'; +import { Platform } from 'react-native'; + +type Props = { + navigation: NavigationProp; + descriptors: { [key: string]: SceneDescriptor }; + navigationConfig: NavigationConfig; + onTransitionStart?: () => void; + onGestureBegin?: () => void; + onGestureCanceled?: () => void; + onGestureEnd?: () => void; + screenProps?: unknown; +}; + +type State = { + routes: Route[]; + descriptors: { [key: string]: SceneDescriptor | undefined }; +}; + +class StackView extends React.Component { + static getDerivedStateFromProps( + props: Readonly, + state: Readonly + ) { + const { index, routes, transitions } = props.navigation.state; + + return { + routes: [ + ...routes.slice(0, index + 1), + // We keep the routes which are popping away in the state so we can transition them + ...state.routes.filter(r => transitions.popping.includes(r.key)), + ], + descriptors: { ...state.descriptors, ...props.descriptors }, + }; + } + + state: State = { + routes: this.props.navigation.state.routes, + descriptors: {}, + }; + + private getTitle = ({ route }: { route: Route }) => { + const descriptor = this.state.descriptors[route.key]; + const { headerTitle, title } = descriptor + ? descriptor.options + : { headerTitle: undefined, title: undefined }; + + return headerTitle !== undefined ? headerTitle : title; + }; + + private renderScene = ({ route }: { route: Route }) => { + const descriptor = this.state.descriptors[route.key]; + + if (!descriptor) { + return null; + } + + const { navigation, getComponent } = descriptor; + const SceneComponent = getComponent(); + + const { screenProps } = this.props; + + return ( + + ); + }; + + private handleGoBack = ({ route }: { route: Route }) => + this.props.navigation.dispatch(StackActions.pop({ key: route.key })); + + private handleTransitionComplete = ({ route }: { route: Route }) => { + this.props.navigation.dispatch( + StackActions.completeTransition({ key: route.key }) + ); + }; + + private handleOpenRoute = ({ route }: { route: Route }) => { + this.handleTransitionComplete({ route }); + }; + + private handleCloseRoute = ({ route }: { route: Route }) => { + this.setState(state => ({ + routes: state.routes.filter(r => r.key !== route.key), + descriptors: { ...state.descriptors, [route.key]: undefined }, + })); + + this.props.navigation.dispatch( + StackActions.pop({ key: route.key, immediate: true }) + ); + + this.handleTransitionComplete({ route }); + }; + + render() { + const { navigation, navigationConfig } = this.props; + const { pushing, popping } = navigation.state.transitions; + + const TransitionPreset = + navigationConfig.mode === 'modal' && Platform.OS === 'ios' + ? ModalSlideFromBottomIOS + : DefaultTransition; + const headerMode = + navigationConfig.headerMode || TransitionPreset.headerMode; + + return ( + + ); + } +} + +export default StackView; diff --git a/src/views/StackView/StackView.tsx b/src/views/StackView/StackView.tsx deleted file mode 100644 index da8eb400f..000000000 --- a/src/views/StackView/StackView.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import * as React from 'react'; - -import { StackActions } from '@react-navigation/core'; -import StackViewLayout from './StackViewLayout'; -import Transitioner from '../Transitioner'; -import TransitionConfigs from './StackViewTransitionConfigs'; -import { - NavigationProp, - SceneDescriptor, - NavigationConfig, - TransitionProps, - Scene, -} from '../../types'; - -type Props = { - navigation: NavigationProp; - descriptors: { [key: string]: SceneDescriptor }; - navigationConfig: NavigationConfig; - onTransitionStart?: () => void; - onGestureBegin?: () => void; - onGestureCanceled?: () => void; - onGestureEnd?: () => void; - screenProps?: unknown; -}; - -const USE_NATIVE_DRIVER = true; - -// NOTE(brentvatne): this was previously in defaultProps, but that is deceiving -// because the entire object will be clobbered by navigationConfig that is -// passed in. -const DefaultNavigationConfig = { - mode: 'card', - cardShadowEnabled: true, - cardOverlayEnabled: false, -}; - -class StackView extends React.Component { - render() { - return ( - - ); - } - - componentDidMount() { - const { navigation } = this.props; - if (navigation.state.isTransitioning) { - navigation.dispatch( - StackActions.completeTransition({ - key: navigation.state.key, - }) - ); - } - } - - private configureTransition = ( - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps - ) => { - return { - useNativeDriver: USE_NATIVE_DRIVER, - ...TransitionConfigs.getTransitionConfig( - this.props.navigationConfig.transitionConfig, - transitionProps, - prevTransitionProps, - this.props.navigationConfig.mode === 'modal' - ).transitionSpec, - }; - }; - - private getShadowEnabled = () => { - const { navigationConfig } = this.props; - return navigationConfig && - navigationConfig.hasOwnProperty('cardShadowEnabled') - ? navigationConfig.cardShadowEnabled - : DefaultNavigationConfig.cardShadowEnabled; - }; - - private getCardOverlayEnabled = () => { - const { navigationConfig } = this.props; - return navigationConfig && - navigationConfig.hasOwnProperty('cardOverlayEnabled') - ? navigationConfig.cardOverlayEnabled - : DefaultNavigationConfig.cardOverlayEnabled; - }; - - private renderStackviewLayout = ( - transitionProps: TransitionProps, - lastTransitionProps?: TransitionProps - ) => { - const { screenProps, navigationConfig } = this.props; - return ( - - ); - }; - - private handleTransitionEnd = ( - transition: { scene: Scene; navigation: NavigationProp }, - lastTransition?: { scene: Scene; navigation: NavigationProp } - ) => { - const { - navigationConfig, - navigation, - // @ts-ignore - onTransitionEnd = navigationConfig.onTransitionEnd, - } = this.props; - const transitionDestKey = transition.scene.route.key; - const isCurrentKey = - navigation.state.routes[navigation.state.index].key === transitionDestKey; - if (transition.navigation.state.isTransitioning && isCurrentKey) { - navigation.dispatch( - StackActions.completeTransition({ - key: navigation.state.key, - toChildKey: transitionDestKey, - }) - ); - } - onTransitionEnd && onTransitionEnd(transition, lastTransition); - }; -} - -export default StackView; diff --git a/src/views/StackView/StackViewCard.tsx b/src/views/StackView/StackViewCard.tsx deleted file mode 100644 index b0b614c9d..000000000 --- a/src/views/StackView/StackViewCard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import * as React from 'react'; -import { - Animated, - StyleSheet, - Platform, - StyleProp, - ViewStyle, -} from 'react-native'; -import { Screen } from 'react-native-screens'; -import createPointerEventsContainer, { - InputProps, - InjectedProps, -} from './createPointerEventsContainer'; - -type Props = InputProps & - InjectedProps & { - style: StyleProp; - animatedStyle: any; - position: Animated.AnimatedInterpolation; - transparent?: boolean; - children: React.ReactNode; - }; - -const EPS = 1e-5; - -function getAccessibilityProps(isActive: boolean) { - if (Platform.OS === 'ios') { - return { - accessibilityElementsHidden: !isActive, - }; - } else if (Platform.OS === 'android') { - return { - importantForAccessibility: isActive ? 'yes' : 'no-hide-descendants', - }; - } else { - return {}; - } -} - -/** - * Component that renders the scene as card for the . - */ -class Card extends React.Component { - render() { - const { - children, - pointerEvents, - style, - position, - transparent, - scene: { index, isActive }, - } = this.props; - - const active: Animated.Value | number | boolean = Platform.select({ - web: isActive, - // @ts-ignore - default: - transparent || isActive - ? 1 - : position.interpolate({ - inputRange: [index, index + 1 - EPS, index + 1], - outputRange: [1, 1, 0], - extrapolate: 'clamp', - }), - }); - - // animatedStyle can be `false` if there is no screen interpolator - const animatedStyle = this.props.animatedStyle || {}; - - const { - shadowOpacity, - overlayOpacity, - ...containerAnimatedStyle - } = animatedStyle; - - let flattenedStyle = StyleSheet.flatten(style) || {}; - let { backgroundColor, ...screenStyle } = flattenedStyle; - - return ( - - {!transparent && shadowOpacity ? ( - - ) : null} - - {children} - - {overlayOpacity ? ( - - ) : null} - - ); - } -} - -const styles = StyleSheet.create({ - card: { - flex: 1, - backgroundColor: '#fff', - }, - overlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: '#000', - }, - shadow: { - top: 0, - left: 0, - bottom: 0, - width: 3, - position: 'absolute', - backgroundColor: '#fff', - shadowOffset: { width: -1, height: 1 }, - shadowRadius: 5, - shadowColor: '#000', - }, - transparent: { - flex: 1, - backgroundColor: 'transparent', - }, -}); - -export default createPointerEventsContainer(Card); diff --git a/src/views/StackView/StackViewLayout.tsx b/src/views/StackView/StackViewLayout.tsx deleted file mode 100644 index 8e4bf278a..000000000 --- a/src/views/StackView/StackViewLayout.tsx +++ /dev/null @@ -1,1009 +0,0 @@ -import * as React from 'react'; -import { - Animated, - StyleSheet, - Platform, - View, - I18nManager, - Easing, - Dimensions, - StyleProp, - ViewStyle, - LayoutChangeEvent, -} from 'react-native'; -import { - SceneView, - StackActions, - NavigationActions, - NavigationProvider, -} from '@react-navigation/core'; -import { withOrientation } from '@react-navigation/native'; -import { ScreenContainer } from 'react-native-screens'; -import { - PanGestureHandler, - State as GestureState, - PanGestureHandlerGestureEvent, - GestureHandlerGestureEventNativeEvent, - PanGestureHandlerEventExtra, -} from 'react-native-gesture-handler'; - -import Card from './StackViewCard'; -import Header from '../Header/Header'; -import TransitionConfigs from './StackViewTransitionConfigs'; -import HeaderStyleInterpolator from '../Header/HeaderStyleInterpolator'; -import StackGestureContext from '../../utils/StackGestureContext'; -import clamp from '../../utils/clamp'; -import { supportsImprovedSpringAnimation } from '../../utils/ReactNativeFeatures'; -import { - Scene, - HeaderMode, - TransitionProps, - TransitionConfig, - HeaderTransitionConfig, - HeaderProps, -} from '../../types'; - -type Props = { - mode: 'modal' | 'card'; - headerMode: 'screen' | 'float'; - headerLayoutPreset: 'left' | 'center'; - headerTransitionPreset: 'fade-in-place' | 'uikit'; - headerBackgroundTransitionPreset: 'fade' | 'translate' | 'toggle'; - headerBackTitleVisible?: boolean; - isLandscape: boolean; - shadowEnabled?: boolean; - cardOverlayEnabled?: boolean; - transparentCard?: boolean; - cardStyle?: StyleProp; - transitionProps: TransitionProps; - lastTransitionProps?: TransitionProps; - transitionConfig: ( - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps, - isModal?: boolean - ) => HeaderTransitionConfig; - onGestureBegin?: () => void; - onGestureEnd?: () => void; - onGestureCanceled?: () => void; - screenProps?: unknown; -}; - -type State = { - floatingHeaderHeight: number; -}; - -const IPHONE_XS_HEIGHT = 812; // iPhone X and XS -const IPHONE_XR_HEIGHT = 896; // iPhone XR and XS Max -const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window'); -const IS_IPHONE_X = - Platform.OS === 'ios' && - // @ts-ignore - !Platform.isPad && - // @ts-ignore - !Platform.isTVOS && - (WINDOW_HEIGHT === IPHONE_XS_HEIGHT || - WINDOW_WIDTH === IPHONE_XS_HEIGHT || - WINDOW_HEIGHT === IPHONE_XR_HEIGHT || - WINDOW_WIDTH === IPHONE_XR_HEIGHT); - -const EaseInOut = Easing.inOut(Easing.ease); - -/** - * Enumerate possible values for validation - */ -const HEADER_LAYOUT_PRESET = ['center', 'left']; -const HEADER_TRANSITION_PRESET = ['fade-in-place', 'uikit']; -const HEADER_BACKGROUND_TRANSITION_PRESET = ['toggle', 'fade', 'translate']; - -/** - * 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 distance of touch start from the edge of the screen where the gesture will be recognized - */ -const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50; -const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135; - -const USE_NATIVE_DRIVER = true; - -const getDefaultHeaderHeight = (isLandscape: boolean) => { - if (Platform.OS === 'ios') { - // @ts-ignore - if (isLandscape && !Platform.isPad) { - return 32; - } else if (IS_IPHONE_X) { - return 88; - } else { - return 64; - } - } else if (Platform.OS === 'android') { - return 56; - } else { - return 64; - } -}; - -class StackViewLayout extends React.Component { - private panGestureRef: React.RefObject; - private gestureX: Animated.Value; - private gestureY: Animated.Value; - private positionSwitch: Animated.Value; - private gestureSwitch: Animated.AnimatedInterpolation; - private gestureEvent: (...args: any[]) => void; - private gesturePosition: Animated.AnimatedInterpolation | undefined; - - // @ts-ignore - private position: Animated.Value; - - /** - * 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. - */ - private immediateIndex: number | null = null; - private transitionConfig: - | HeaderTransitionConfig & TransitionConfig - | undefined; - private prevProps: Props | undefined; - - constructor(props: Props) { - super(props); - this.panGestureRef = React.createRef(); - this.gestureX = new Animated.Value(0); - this.gestureY = new Animated.Value(0); - this.positionSwitch = new Animated.Value(1); - if (Animated.subtract) { - this.gestureSwitch = Animated.subtract(1, this.positionSwitch); - } else { - this.gestureSwitch = Animated.add( - 1, - Animated.multiply(-1, this.positionSwitch) - ); - } - this.gestureEvent = Animated.event( - [ - { - nativeEvent: { - translationX: this.gestureX, - translationY: this.gestureY, - }, - }, - ], - { - useNativeDriver: USE_NATIVE_DRIVER, - } - ); - - this.state = { - // Used when card's header is null and mode is float to make transition - // between screens with headers and those without headers smooth. - // This is not a great heuristic here. We don't know synchronously - // on mount what the header height is so we have just used the most - // common cases here. - floatingHeaderHeight: getDefaultHeaderHeight(props.isLandscape), - }; - } - - private renderHeader(scene: Scene, headerMode: HeaderMode) { - const { options } = scene.descriptor; - const { header } = options; - - if (__DEV__ && typeof header === 'string') { - throw new Error( - `Invalid header value: "${header}". The header option must be a valid React component or null, not a string.` - ); - } - - if (header === null && headerMode === 'screen') { - return null; - } - - // check if it's a react element - if (React.isValidElement(header)) { - return header; - } - - // Handle the case where the header option is a function, and provide the default - const renderHeader = - // @ts-ignore TS warns about missing props, but they are in default props - header || ((props: HeaderProps) =>
); - - let { - headerLeftInterpolator, - headerTitleInterpolator, - headerRightInterpolator, - headerBackgroundInterpolator, - } = this.transitionConfig as HeaderTransitionConfig; - - const backgroundTransitionPresetInterpolator = this.getHeaderBackgroundTransitionPreset(); - if (backgroundTransitionPresetInterpolator) { - headerBackgroundInterpolator = backgroundTransitionPresetInterpolator; - } - - const { transitionProps, ...passProps } = this.props; - - return ( - - {renderHeader({ - ...passProps, - ...transitionProps, - position: this.position, - scene, - mode: headerMode, - transitionPreset: this.getHeaderTransitionPreset(), - layoutPreset: this.getHeaderLayoutPreset(), - backTitleVisible: this.getHeaderBackTitleVisible(), - leftInterpolator: headerLeftInterpolator, - titleInterpolator: headerTitleInterpolator, - rightInterpolator: headerRightInterpolator, - backgroundInterpolator: headerBackgroundInterpolator, - })} - - ); - } - - private reset(resetToIndex: number, duration: number) { - if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) { - // @ts-ignore - Animated.spring(this.props.transitionProps.position, { - toValue: resetToIndex, - stiffness: 6000, - damping: 100, - mass: 3, - overshootClamping: true, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, - useNativeDriver: USE_NATIVE_DRIVER, - }).start(); - } else { - // @ts-ignore - Animated.timing(this.props.transitionProps.position, { - toValue: resetToIndex, - duration, - easing: EaseInOut, - useNativeDriver: USE_NATIVE_DRIVER, - }).start(); - } - } - - private goBack(backFromIndex: number, duration: number) { - 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 (backFromScene) { - navigation.dispatch( - NavigationActions.back({ - key: backFromScene.route.key, - immediate: true, - }) - ); - navigation.dispatch(StackActions.completeTransition()); - } - }; - - if (Platform.OS === 'ios' && supportsImprovedSpringAnimation()) { - // @ts-ignore - Animated.spring(position, { - toValue, - stiffness: 7000, - damping: 300, - mass: 3, - overshootClamping: true, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, - useNativeDriver: USE_NATIVE_DRIVER, - }).start(onCompleteAnimation); - } else { - // @ts-ignore - Animated.timing(position, { - toValue, - duration, - easing: EaseInOut, - useNativeDriver: USE_NATIVE_DRIVER, - }).start(onCompleteAnimation); - } - } - - private handleFloatingHeaderLayout = (e: LayoutChangeEvent) => { - const { height } = e.nativeEvent.layout; - if (height !== this.state.floatingHeaderHeight) { - this.setState({ floatingHeaderHeight: height }); - } - }; - - private prepareAnimated() { - if (this.props === this.prevProps) { - return; - } - this.prevProps = this.props; - - this.prepareGesture(); - this.preparePosition(); - this.prepareTransitionConfig(); - } - - render() { - this.prepareAnimated(); - - const { transitionProps } = this.props; - const { - navigation: { - state: { index }, - }, - scenes, - } = transitionProps; - - const headerMode = this.getHeaderMode(); - let floatingHeader = null; - if (headerMode === 'float') { - const { scene } = transitionProps; - floatingHeader = ( - - {this.renderHeader(scene, headerMode)} - - ); - } - - return ( - 0 && this.isGestureEnabled()} - > - - - - {scenes.map(this.renderCard)} - - {floatingHeader} - - - - ); - } - - componentDidUpdate(prevProps: Props) { - const { state: prevState } = prevProps.transitionProps.navigation; - const { state } = this.props.transitionProps.navigation; - if (prevState.index !== state.index) { - this.maybeCancelGesture(); - } - } - - private getGestureResponseDistance() { - const { scene } = this.props.transitionProps; - const { options } = scene.descriptor; - const { - gestureResponseDistance: userGestureResponseDistance = {} as { - vertical?: number; - horizontal?: number; - }, - } = options; - - // Doesn't make sense for a response distance of 0, so this works fine - return this.isModal() - ? userGestureResponseDistance.vertical || - GESTURE_RESPONSE_DISTANCE_VERTICAL - : userGestureResponseDistance.horizontal || - GESTURE_RESPONSE_DISTANCE_HORIZONTAL; - } - - private gestureActivationCriteria() { - const { layout } = this.props.transitionProps; - const gestureResponseDistance = this.getGestureResponseDistance(); - const isMotionInverted = this.isMotionInverted(); - - if (this.isMotionVertical()) { - // @ts-ignore - const height: number = layout.height.__getValue(); - - return { - maxDeltaX: 15, - minOffsetY: isMotionInverted ? -5 : 5, - hitSlop: isMotionInverted - ? { top: -height + gestureResponseDistance } - : { bottom: -height + gestureResponseDistance }, - }; - } else { - // @ts-ignore - const width: number = layout.width.__getValue(); - const hitSlop = -width + gestureResponseDistance; - - return { - minOffsetX: isMotionInverted ? -5 : 5, - maxDeltaY: 20, - hitSlop: isMotionInverted ? { left: hitSlop } : { right: hitSlop }, - }; - } - } - - private isGestureEnabled() { - const gesturesEnabled = this.props.transitionProps.scene.descriptor.options - .gesturesEnabled; - return typeof gesturesEnabled === 'boolean' - ? gesturesEnabled - : Platform.OS === 'ios'; - } - - private isMotionVertical() { - return this.isModal(); - } - - private isModal() { - return this.props.mode === 'modal'; - } - - // This only currently applies to the horizontal gesture! - private isMotionInverted() { - const { - transitionProps: { scene }, - } = this.props; - const { options } = scene.descriptor; - const { gestureDirection } = options; - - if (this.isModal()) { - return gestureDirection === 'inverted'; - } else { - return typeof gestureDirection === 'string' - ? gestureDirection === 'inverted' - : I18nManager.isRTL; - } - } - - private computeHorizontalGestureValue({ - translationX, - }: { - translationX: number; - }) { - const { - transitionProps: { navigation, layout }, - } = this.props; - - const { index } = navigation.state; - - // TODO: remove this __getValue! - // @ts-ignore - const distance: number = layout.width.__getValue(); - - const x = this.isMotionInverted() ? -1 * translationX : translationX; - - const value = index - x / distance; - return clamp(index - 1, value, index); - } - - private computeVerticalGestureValue({ - translationY, - }: { - translationY: number; - }) { - const { - transitionProps: { navigation, layout }, - } = this.props; - - const { index } = navigation.state; - - // TODO: remove this __getValue! - // @ts-ignore - const distance: number = layout.height.__getValue(); - - const y = this.isMotionInverted() ? -1 * translationY : translationY; - const value = index - y / distance; - return clamp(index - 1, value, index); - } - - private handlePanGestureStateChange = ({ - nativeEvent, - }: PanGestureHandlerGestureEvent) => { - // @ts-ignore - if (nativeEvent.oldState === GestureState.ACTIVE) { - // Gesture was cancelled! For example, some navigation state update - // arrived while the gesture was active that cancelled it out - // @ts-ignore - if (this.positionSwitch.__getValue() === 1) { - return; - } - - if (this.isMotionVertical()) { - this.handleReleaseVertical(nativeEvent); - } else { - this.handleReleaseHorizontal(nativeEvent); - } - } else if (nativeEvent.state === GestureState.ACTIVE) { - this.props.onGestureBegin && this.props.onGestureBegin(); - - // Switch to using gesture position - this.positionSwitch.setValue(0); - - // By enabling the gesture switch and ignoring the position here we - // end up with a quick jump to the initial value and then back to the - // gesture. While this isn't ideal, it's preferred over preventing new - // gestures during the animation (all gestures should be interruptible) - // and we will properly fix it (interruptible and from the correct position) - // when we integrate reanimated. If you prefer to prevent gestures during - // transitions, then fork this library, comment the positionSwitch value set above, - // and uncomment the following two lines. - // if (!this.props.transitionProps.position._animation) { - // this.positionSwitch.setValue(0); - // } - } - }; - - // note: this will not animated so nicely because the position is unaware - // of the gesturePosition, so if we are in the middle of swiping the screen away - // and back is programatically fired then we will reset to the initial position - // and animate from there - private maybeCancelGesture() { - this.positionSwitch.setValue(1); - } - - private prepareGesture() { - if (!this.isGestureEnabled()) { - // @ts-ignore - if (this.positionSwitch.__getValue() !== 1) { - this.positionSwitch.setValue(1); - } - this.gesturePosition = undefined; - return; - } - - // We can't run the gesture if width or height layout is unavailable - if ( - // @ts-ignore - this.props.transitionProps.layout.width.__getValue() === 0 || - // @ts-ignore - this.props.transitionProps.layout.height.__getValue() === 0 - ) { - return; - } - - if (this.isMotionVertical()) { - this.prepareGestureVertical(); - } else { - this.prepareGestureHorizontal(); - } - } - - private prepareGestureHorizontal() { - const { index } = this.props.transitionProps.navigation.state; - - if (this.isMotionInverted()) { - this.gesturePosition = Animated.add( - index, - Animated.divide(this.gestureX, this.props.transitionProps.layout.width) - ).interpolate({ - inputRange: [index - 1, index], - outputRange: [index - 1, index], - extrapolate: 'clamp', - }); - } else { - this.gesturePosition = Animated.add( - index, - Animated.multiply( - -1, - Animated.divide( - this.gestureX, - this.props.transitionProps.layout.width - ) - ) - ).interpolate({ - inputRange: [index - 1, index], - outputRange: [index - 1, index], - extrapolate: 'clamp', - }); - } - } - - private prepareGestureVertical() { - const { index } = this.props.transitionProps.navigation.state; - - if (this.isMotionInverted()) { - this.gesturePosition = Animated.add( - index, - Animated.divide(this.gestureY, this.props.transitionProps.layout.height) - ).interpolate({ - inputRange: [index - 1, index], - outputRange: [index - 1, index], - extrapolate: 'clamp', - }); - } else { - this.gesturePosition = Animated.add( - index, - Animated.multiply( - -1, - Animated.divide( - this.gestureY, - this.props.transitionProps.layout.height - ) - ) - ).interpolate({ - inputRange: [index - 1, index], - outputRange: [index - 1, index], - extrapolate: 'clamp', - }); - } - } - - private handleReleaseHorizontal( - nativeEvent: GestureHandlerGestureEventNativeEvent & - PanGestureHandlerEventExtra - ) { - const { - transitionProps: { navigation, position, layout }, - } = this.props; - const { index } = navigation.state; - const immediateIndex = - this.immediateIndex == null ? index : this.immediateIndex; - - // Calculate animate duration according to gesture speed and moved distance - // @ts-ignore - const distance = layout.width.__getValue(); - const movementDirection = this.isMotionInverted() ? -1 : 1; - const movedDistance = movementDirection * nativeEvent.translationX; - const gestureVelocity = movementDirection * nativeEvent.velocityX; - const defaultVelocity = distance / ANIMATION_DURATION; - const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity); - const resetDuration = this.isMotionInverted() - ? (distance - movedDistance) / velocity - : movedDistance / velocity; - const goBackDuration = this.isMotionInverted() - ? movedDistance / velocity - : (distance - movedDistance) / velocity; - - // Get the current position value and reset to using the statically driven - // (rather than gesture driven) position. - const value = this.computeHorizontalGestureValue(nativeEvent); - position.setValue(value); - this.positionSwitch.setValue(1); - - // If the speed of the gesture release is significant, use that as the indication - // of intent - if (gestureVelocity < -50) { - this.props.onGestureCanceled && this.props.onGestureCanceled(); - this.reset(immediateIndex, resetDuration); - return; - } - if (gestureVelocity > 50) { - this.props.onGestureEnd && this.props.onGestureEnd(); - 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.props.onGestureEnd && this.props.onGestureEnd(); - this.goBack(immediateIndex, goBackDuration); - } else { - this.props.onGestureCanceled && this.props.onGestureCanceled(); - this.reset(immediateIndex, resetDuration); - } - } - - private handleReleaseVertical( - nativeEvent: GestureHandlerGestureEventNativeEvent & - PanGestureHandlerEventExtra - ) { - const { - transitionProps: { navigation, position, layout }, - } = this.props; - const { index } = navigation.state; - const immediateIndex = - this.immediateIndex == null ? index : this.immediateIndex; - - // Calculate animate duration according to gesture speed and moved distance - // @ts-ignore - const distance = layout.height.__getValue(); - const isMotionInverted = this.isMotionInverted(); - const movementDirection = isMotionInverted ? -1 : 1; - const movedDistance = movementDirection * nativeEvent.translationY; - const gestureVelocity = movementDirection * nativeEvent.velocityY; - const defaultVelocity = distance / ANIMATION_DURATION; - const velocity = Math.max(Math.abs(gestureVelocity), defaultVelocity); - const resetDuration = isMotionInverted - ? (distance - movedDistance) / velocity - : movedDistance / velocity; - const goBackDuration = isMotionInverted - ? movedDistance / velocity - : (distance - movedDistance) / velocity; - - const value = this.computeVerticalGestureValue(nativeEvent); - position.setValue(value); - this.positionSwitch.setValue(1); - - // If the speed of the gesture release is significant, use that as the indication - // of intent - if (gestureVelocity < -50) { - this.props.onGestureCanceled && this.props.onGestureCanceled(); - this.reset(immediateIndex, resetDuration); - return; - } - if (gestureVelocity > 50) { - this.props.onGestureEnd && this.props.onGestureEnd(); - 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.props.onGestureEnd && this.props.onGestureEnd(); - this.goBack(immediateIndex, goBackDuration); - } else { - this.props.onGestureCanceled && this.props.onGestureCanceled(); - this.reset(immediateIndex, resetDuration); - } - } - - private getHeaderMode() { - if (this.props.headerMode) { - return this.props.headerMode; - } - if (Platform.OS === 'android' || this.props.mode === 'modal') { - return 'screen'; - } - // On web, the float header mode will enable body scrolling and stick the header - // to the top of the URL bar when it shrinks and expands. - return 'float'; - } - - private getHeaderBackgroundTransitionPreset() { - const { headerBackgroundTransitionPreset } = this.props; - if (headerBackgroundTransitionPreset) { - if ( - HEADER_BACKGROUND_TRANSITION_PRESET.includes( - headerBackgroundTransitionPreset - ) - ) { - if (headerBackgroundTransitionPreset === 'fade') { - return HeaderStyleInterpolator.forBackgroundWithFade; - } else if (headerBackgroundTransitionPreset === 'translate') { - return HeaderStyleInterpolator.forBackgroundWithTranslation; - } else if (headerBackgroundTransitionPreset === 'toggle') { - return HeaderStyleInterpolator.forBackgroundWithInactiveHidden; - } - } else if (__DEV__) { - console.error( - `Invalid configuration applied for headerBackgroundTransitionPreset - expected one of ${HEADER_BACKGROUND_TRANSITION_PRESET.join( - ', ' - )} but received ${JSON.stringify(headerBackgroundTransitionPreset)}` - ); - } - } - - return null; - } - - private getHeaderLayoutPreset() { - const { headerLayoutPreset } = this.props; - if (headerLayoutPreset) { - if (__DEV__) { - if ( - this.getHeaderTransitionPreset() === 'uikit' && - headerLayoutPreset === 'left' && - Platform.OS === 'ios' - ) { - console.warn( - `headerTransitionPreset with the value 'uikit' is incompatible with headerLayoutPreset 'left'` - ); - } - } - if (HEADER_LAYOUT_PRESET.includes(headerLayoutPreset)) { - return headerLayoutPreset; - } - - if (__DEV__) { - console.error( - `Invalid configuration applied for headerLayoutPreset - expected one of ${HEADER_LAYOUT_PRESET.join( - ', ' - )} but received ${JSON.stringify(headerLayoutPreset)}` - ); - } - } - - if (Platform.OS !== 'ios') { - return 'left'; - } else { - return 'center'; - } - } - - private 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 !== 'ios' || this.getHeaderMode() === 'screen') { - return 'fade-in-place'; - } - - const { headerTransitionPreset } = this.props; - if (headerTransitionPreset) { - if (HEADER_TRANSITION_PRESET.includes(headerTransitionPreset)) { - return headerTransitionPreset; - } - - if (__DEV__) { - console.error( - `Invalid configuration applied for headerTransitionPreset - expected one of ${HEADER_TRANSITION_PRESET.join( - ', ' - )} but received ${JSON.stringify(headerTransitionPreset)}` - ); - } - } - - return 'fade-in-place'; - } - - private getHeaderBackTitleVisible() { - const { headerBackTitleVisible } = this.props; - const layoutPreset = this.getHeaderLayoutPreset(); - - // Even when we align to center on Android, people should need to opt-in to - // showing the back title - const enabledByDefault = !( - layoutPreset === 'left' || Platform.OS !== 'ios' - ); - - return typeof headerBackTitleVisible === 'boolean' - ? headerBackTitleVisible - : enabledByDefault; - } - - private renderInnerScene(scene: Scene) { - const { navigation, getComponent } = scene.descriptor; - const SceneComponent = getComponent(); - - const { screenProps } = this.props; - const headerMode = this.getHeaderMode(); - if (headerMode === 'screen') { - return ( - - - - - {this.renderHeader(scene, headerMode)} - - ); - } - return ( - - ); - } - - private prepareTransitionConfig() { - this.transitionConfig = TransitionConfigs.getTransitionConfig( - this.props.transitionConfig, - { - ...this.props.transitionProps, - position: this.position, - }, - this.props.lastTransitionProps, - this.isModal() - ); - } - - private preparePosition() { - if (this.gesturePosition) { - // FIXME: this doesn't seem right, there is setValue called in some places - // @ts-ignore - this.position = Animated.add( - Animated.multiply( - this.props.transitionProps.position, - this.positionSwitch - ), - Animated.multiply(this.gesturePosition, this.gestureSwitch) - ); - } else { - this.position = this.props.transitionProps.position; - } - } - - private renderCard = (scene: Scene) => { - const { - transitionProps, - shadowEnabled, - cardOverlayEnabled, - transparentCard, - cardStyle, - } = this.props; - - const { screenInterpolator } = this.transitionConfig as TransitionConfig; - const style = - screenInterpolator && - screenInterpolator({ - ...transitionProps, - shadowEnabled, - cardOverlayEnabled, - position: this.position, - scene, - }); - - // When using a floating header, we need to add some top - // padding on the scene. - const { options } = scene.descriptor; - const hasHeader = options.header !== null; - const headerMode = this.getHeaderMode(); - - let floatingContainerStyle: ViewStyle = StyleSheet.absoluteFill as ViewStyle; - - if (hasHeader && headerMode === 'float' && !options.headerTransparent) { - floatingContainerStyle = { - ...Platform.select({ web: {}, default: StyleSheet.absoluteFillObject }), - paddingTop: this.state.floatingHeaderHeight, - }; - } - - return ( - - {this.renderInnerScene(scene)} - - ); - }; -} - -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', - overflow: 'hidden', - }, - scenes: { - flex: 1, - }, - floatingHeader: { - // @ts-ignore - position: Platform.select({ default: 'absolute', web: 'fixed' }), - left: 0, - top: 0, - right: 0, - }, -}); - -export default withOrientation(StackViewLayout); diff --git a/src/views/StackView/StackViewStyleInterpolator.tsx b/src/views/StackView/StackViewStyleInterpolator.tsx deleted file mode 100644 index 55a612a3b..000000000 --- a/src/views/StackView/StackViewStyleInterpolator.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { I18nManager } from 'react-native'; -import getSceneIndicesForInterpolationInputRange from '../../utils/getSceneIndicesForInterpolationInputRange'; -import { SceneInterpolatorProps } from '../../types'; - -const EPS = 1e-5; - -/** - * 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: SceneInterpolatorProps) { - const { navigation, scene } = props; - - const focused = navigation.state.index === scene.index; - const opacity = focused ? 1 : 0; - // If not focused, move the scene 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: SceneInterpolatorProps) { - 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 width = layout.initWidth; - const translateX = position.interpolate({ - inputRange: [first, index, last], - outputRange: I18nManager.isRTL - ? [-width, 0, width * 0.3] - : [width, 0, width * -0.3], - extrapolate: 'clamp', - }); - - const shadowOpacity = props.shadowEnabled - ? position.interpolate({ - inputRange: [first, index, last], - outputRange: [0, 0.7, 0], - extrapolate: 'clamp', - }) - : null; - - let overlayOpacity = props.cardOverlayEnabled - ? position.interpolate({ - inputRange: [index, last - 0.5, last, last + EPS], - outputRange: [0, 0.07, 0.07, 0], - extrapolate: 'clamp', - }) - : null; - - return { - transform: [{ translateX }], - overlayOpacity, - shadowOpacity, - }; -} - -/** - * Standard iOS-style slide in from the bottom (used for modals). - */ -function forVertical(props: SceneInterpolatorProps) { - 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 height = layout.initHeight; - const translateY = position.interpolate({ - inputRange: [first, index, last], - outputRange: [height, 0, 0], - extrapolate: 'clamp', - }); - - return { - transform: [{ translateY }], - }; -} - -/** - * Standard Android-style fade in from the bottom. - */ -function forFadeFromBottomAndroid(props: SceneInterpolatorProps) { - 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, first + 0.5, first + 0.9, index, last - 1e-5, last], - outputRange: [0, 0.25, 0.7, 1, 1, 0], - extrapolate: 'clamp', - }); - - const height = layout.initHeight; - const maxTranslation = height * 0.08; - const translateY = position.interpolate({ - inputRange: [first, index, last], - outputRange: [maxTranslation, 0, 0], - extrapolate: 'clamp', - }); - - return { - opacity, - transform: [{ translateY }], - }; -} - -function forFadeToBottomAndroid(props: SceneInterpolatorProps) { - 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]; - - const opacity = position.interpolate({ - inputRange, - outputRange: [0, 1, 1], - extrapolate: 'clamp', - }); - - const height = layout.initHeight; - const maxTranslation = height * 0.08; - - const translateY = position.interpolate({ - inputRange, - outputRange: [maxTranslation, 0, 0], - extrapolate: 'clamp', - }); - - return { - opacity, - transform: [{ translateY }], - }; -} - -/** - * fadeIn and fadeOut - */ -function forFade(props: SceneInterpolatorProps) { - 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], - extrapolate: 'clamp', - }); - - return { - opacity, - }; -} - -function forNoAnimation() { - return {}; -} - -export default { - forHorizontal, - forVertical, - forFadeFromBottomAndroid, - forFadeToBottomAndroid, - forFade, - forNoAnimation, -}; diff --git a/src/views/StackView/StackViewTransitionConfigs.tsx b/src/views/StackView/StackViewTransitionConfigs.tsx deleted file mode 100644 index 65636e3da..000000000 --- a/src/views/StackView/StackViewTransitionConfigs.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Animated, Easing, Platform } from 'react-native'; -import StyleInterpolator from './StackViewStyleInterpolator'; -import { supportsImprovedSpringAnimation } from '../../utils/ReactNativeFeatures'; -import { TransitionProps, TransitionConfig } from '../../types'; - -let IOSTransitionSpec; -if (supportsImprovedSpringAnimation()) { - // These are the exact values from UINavigationController's animation configuration - IOSTransitionSpec = { - timing: Animated.spring, - stiffness: 1000, - damping: 500, - mass: 3, - overshootClamping: true, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, - }; -} 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: '#eee', - }, -}; - -// Standard iOS navigation transition for modals -const ModalSlideFromBottomIOS = { - transitionSpec: IOSTransitionSpec, - screenInterpolator: StyleInterpolator.forVertical, - containerStyle: { - backgroundColor: '#eee', - }, -}; - -// 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: 150, - easing: Easing.in(Easing.linear), // accelerate - timing: Animated.timing, - }, - screenInterpolator: StyleInterpolator.forFadeToBottomAndroid, -}; - -const NoAnimation = { - transitionSpec: { - duration: 0, - timing: Animated.timing, - }, - screenInterpolator: StyleInterpolator.forNoAnimation, - containerStyle: { - backgroundColor: '#eee', - }, -}; - -function defaultTransitionConfig( - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps, - isModal?: boolean -): TransitionConfig { - if (Platform.OS !== 'ios') { - // 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: - | undefined - | (( - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps, - isModal?: boolean - ) => T), - transitionProps: TransitionProps, - prevTransitionProps?: TransitionProps, - isModal?: boolean -): TransitionConfig & T { - const defaultConfig = defaultTransitionConfig( - transitionProps, - prevTransitionProps, - isModal - ); - if (transitionConfigurer) { - return { - ...defaultConfig, - ...transitionConfigurer(transitionProps, prevTransitionProps, isModal), - }; - } - - return defaultConfig as any; -} - -export default { - defaultTransitionConfig, - getTransitionConfig, - SlideFromRightIOS, - ModalSlideFromBottomIOS, - FadeInFromBottomAndroid, - FadeOutToBottomAndroid, - NoAnimation, -}; diff --git a/src/views/StackView/createPointerEventsContainer.tsx b/src/views/StackView/createPointerEventsContainer.tsx deleted file mode 100644 index 81caf7958..000000000 --- a/src/views/StackView/createPointerEventsContainer.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import * as React from 'react'; -import { Animated, View } from 'react-native'; -import { NavigationProp, Scene } from '../../types'; - -const MIN_POSITION_OFFSET = 0.01; - -export type PointerEvents = 'box-only' | 'none' | 'auto'; - -export type InputProps = { - scene: Scene; - navigation: NavigationProp; - realPosition: Animated.Value; -}; - -export type InjectedProps = { - pointerEvents: PointerEvents; - onComponentRef: (ref: View | null) => void; -}; - -/** - * Create a higher-order component that automatically computes the - * `pointerEvents` property for a component whenever navigation position - * changes. - */ -export default function createPointerEventsContainer< - Props extends InjectedProps & InputProps ->( - Component: React.ComponentType -): React.ComponentType>> { - class Container extends React.Component { - private pointerEvents = this.computePointerEvents(); - private component: View | null = null; - private positionListener: AnimatedValueSubscription | undefined; - - componentWillUnmount() { - this.positionListener && this.positionListener.remove(); - } - - private handleComponentRef = (component: View | null) => { - this.component = component; - - if (component && typeof component.setNativeProps !== 'function') { - throw new Error('Component must implement method `setNativeProps`'); - } - }; - - private bindPosition() { - this.positionListener && this.positionListener.remove(); - this.positionListener = new AnimatedValueSubscription( - this.props.realPosition, - this.handlePositionChange - ); - } - - private handlePositionChange = (/* { value } */) => { - // This should log each frame when releasing the gesture or when pressing - // the back button! If not, something has gone wrong with the animated - // value subscription - // console.log(value); - - if (this.component) { - const pointerEvents = this.computePointerEvents(); - if (this.pointerEvents !== pointerEvents) { - this.pointerEvents = pointerEvents; - this.component.setNativeProps({ pointerEvents }); - } - } - }; - - private computePointerEvents() { - const { navigation, realPosition, scene } = this.props; - - if (scene.isStale || navigation.state.index !== scene.index) { - // The scene isn't focused. - return scene.index > navigation.state.index ? 'box-only' : 'none'; - } - - // @ts-ignore - const offset = realPosition.__getAnimatedValue() - navigation.state.index; - if (Math.abs(offset) > MIN_POSITION_OFFSET) { - // 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. - return 'box-only'; - } - - return 'auto'; - } - - render() { - this.bindPosition(); - this.pointerEvents = this.computePointerEvents(); - - return ( - - ); - } - } - - return Container as any; -} - -class AnimatedValueSubscription { - private value: Animated.Value; - private token: string; - - constructor(value: Animated.Value, callback: Animated.ValueListenerCallback) { - this.value = value; - this.token = value.addListener(callback); - } - - remove() { - this.value.removeListener(this.token); - } -} diff --git a/src/views/Transitioner.tsx b/src/views/Transitioner.tsx deleted file mode 100644 index b292f7067..000000000 --- a/src/views/Transitioner.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import * as React from 'react'; -import { - Animated, - Easing, - StyleSheet, - View, - LayoutChangeEvent, -} from 'react-native'; - -import NavigationScenesReducer from './ScenesReducer'; -import { - NavigationProp, - Scene, - SceneDescriptor, - TransitionerLayout, - TransitionProps, -} from '../types'; - -type TransitionSpec = {}; - -type Props = { - render: ( - current: TransitionProps, - previous?: TransitionProps - ) => React.ReactNode; - configureTransition?: ( - current: TransitionProps, - previous?: TransitionProps - ) => TransitionSpec; - onTransitionStart?: ( - current: TransitionProps, - previous?: TransitionProps - ) => void | Promise; - onTransitionEnd?: ( - current: TransitionProps, - previous?: TransitionProps - ) => void | Promise; - navigation: NavigationProp; - descriptors: { [key: string]: SceneDescriptor }; - screenProps?: unknown; -}; - -type State = { - layout: TransitionerLayout; - position: Animated.Value; - scenes: Scene[]; - nextScenes?: Scene[]; -}; - -// Used for all animations unless overriden -const DefaultTransitionSpec = { - duration: 250, - easing: Easing.inOut(Easing.ease), - timing: Animated.timing, -}; - -class Transitioner extends React.Component { - private positionListener: string; - - private prevTransitionProps: TransitionProps | undefined; - private transitionProps: TransitionProps; - - private isComponentMounted: boolean; - private isTransitionRunning: boolean; - private queuedTransition: { prevProps: Props } | null; - - constructor(props: Props) { - super(props); - - // The initial layout isn't measured. Measured layout will be only available - // when the component is mounted. - const layout: TransitionerLayout = { - height: new Animated.Value(0), - initHeight: 0, - initWidth: 0, - isMeasured: false, - width: new Animated.Value(0), - }; - - const position = new Animated.Value(this.props.navigation.state.index); - this.positionListener = position.addListener((/* { value } */) => { - // This should work until we detach position from a view! so we have to be - // careful to not ever detach it, thus the gymnastics in _getPosition in - // StackViewLayout - // This should log each frame when releasing the gesture or when pressing - // the back button! If not, something has gone wrong with the animated - // value subscription - // console.log(value); - }); - - this.state = { - layout, - position, - scenes: NavigationScenesReducer( - [], - this.props.navigation.state, - null, - this.props.descriptors - ), - }; - - this.prevTransitionProps = undefined; - this.transitionProps = buildTransitionProps(props, this.state); - - this.isComponentMounted = false; - this.isTransitionRunning = false; - this.queuedTransition = null; - } - - componentDidMount() { - this.isComponentMounted = true; - } - - componentWillUnmount() { - this.isComponentMounted = false; - this.positionListener && - this.state.position.removeListener(this.positionListener); - } - - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if (this.isTransitionRunning) { - if (!this.queuedTransition) { - this.queuedTransition = { prevProps: this.props }; - } - return; - } - - this.startTransition(this.props, nextProps); - } - - private computeScenes = (props: Props, nextProps: Props) => { - let nextScenes = NavigationScenesReducer( - this.state.scenes, - nextProps.navigation.state, - props.navigation.state, - nextProps.descriptors - ); - - if (!nextProps.navigation.state.isTransitioning) { - nextScenes = filterStale(nextScenes); - } - - // Update nextScenes when we change screenProps - // This is a workaround for https://github.com/react-navigation/react-navigation/issues/4271 - if (nextProps.screenProps !== this.props.screenProps) { - this.setState({ nextScenes }); - } - - if (nextScenes === this.state.scenes) { - return; - } - - return nextScenes; - }; - - private startTransition(props: Props, nextProps: Props) { - const indexHasChanged = - props.navigation.state.index !== nextProps.navigation.state.index; - let nextScenes = this.computeScenes(props, nextProps); - - if (!nextScenes) { - // prevTransitionProps are the same as transitionProps in this case - // because nothing changed - this.prevTransitionProps = this.transitionProps; - - // Unsure if this is actually a good idea... Also related to - // https://github.com/react-navigation/react-navigation/issues/5247 - // - the animation is interrupted before completion so this ensures - // that it is properly set to the final position before firing - // onTransitionEnd - this.state.position.setValue(props.navigation.state.index); - - this.handleTransitionEnd(); - return; - } - - const nextState = { - ...this.state, - scenes: nextScenes, - }; - - // grab the position animated value - const { position } = nextState; - - // determine where we are meant to transition to - const toValue = nextProps.navigation.state.index; - - // compute transitionProps - this.prevTransitionProps = this.transitionProps; - this.transitionProps = buildTransitionProps(nextProps, nextState); - let { isTransitioning } = this.transitionProps.navigation.state; - - // if the state isn't transitioning that is meant to signal that we should - // transition immediately to the new index. if the index hasn't changed, do - // the same thing here. it's not clear to me why we ever start a transition - // when the index hasn't changed, this requires further investigation. - if (!isTransitioning || !indexHasChanged) { - this.setState(nextState, async () => { - if (nextProps.onTransitionStart) { - const result = nextProps.onTransitionStart( - this.transitionProps, - this.prevTransitionProps - ); - if (result instanceof Promise) { - // why do we bother awaiting the result here? - await result; - } - } - // jump immediately to the new value - indexHasChanged && position.setValue(toValue); - // end the transition - this.handleTransitionEnd(); - }); - } else if (isTransitioning) { - this.isTransitionRunning = true; - this.setState(nextState, async () => { - if (nextProps.onTransitionStart) { - const result = nextProps.onTransitionStart( - this.transitionProps, - this.prevTransitionProps - ); - - // Wait for the onTransitionStart to resolve if needed. - if (result instanceof Promise) { - await result; - } - } - - // get the transition spec. - const transitionUserSpec = nextProps.configureTransition - ? nextProps.configureTransition( - this.transitionProps, - this.prevTransitionProps - ) - : null; - - const transitionSpec = { - ...DefaultTransitionSpec, - ...transitionUserSpec, - }; - - const { timing } = transitionSpec; - delete transitionSpec.timing; - - // if swiped back, indexHasChanged == true && positionHasChanged == false - // @ts-ignore - const positionHasChanged = position.__getValue() !== toValue; - if (indexHasChanged && positionHasChanged) { - timing(position, { - ...transitionSpec, - toValue: nextProps.navigation.state.index, - }).start(() => { - // In case the animation is immediately interrupted for some reason, - // we move this to the next frame so that onTransitionStart can fire - // first (https://github.com/react-navigation/react-navigation/issues/5247) - requestAnimationFrame(this.handleTransitionEnd); - }); - } else { - this.handleTransitionEnd(); - } - }); - } - } - - render() { - return ( - - {this.props.render(this.transitionProps, this.prevTransitionProps)} - - ); - } - - private handleLayout = (event: LayoutChangeEvent) => { - const { height, width } = event.nativeEvent.layout; - if ( - this.state.layout.initWidth === width && - this.state.layout.initHeight === height - ) { - return; - } - const layout: TransitionerLayout = { - ...this.state.layout, - initHeight: height, - initWidth: width, - isMeasured: true, - }; - - layout.height.setValue(height); - layout.width.setValue(width); - - const nextState = { - ...this.state, - layout, - }; - - this.transitionProps = buildTransitionProps(this.props, nextState); - this.setState(nextState); - }; - - private handleTransitionEnd = () => { - if (!this.isComponentMounted) { - return; - } - const prevTransitionProps = this.prevTransitionProps; - this.prevTransitionProps = undefined; - - const scenes = filterStale(this.state.scenes); - - const nextState = { - ...this.state, - scenes, - }; - - this.transitionProps = buildTransitionProps(this.props, nextState); - - this.setState(nextState, async () => { - if (this.props.onTransitionEnd) { - const result = this.props.onTransitionEnd( - this.transitionProps, - prevTransitionProps - ); - - if (result instanceof Promise) { - await result; - } - } - - if (this.queuedTransition) { - let { prevProps } = this.queuedTransition; - this.queuedTransition = null; - this.startTransition(prevProps, this.props); - } else { - this.isTransitionRunning = false; - } - }); - }; -} - -function buildTransitionProps(props: Props, state: State): TransitionProps { - const { navigation } = props; - - const { layout, position, scenes } = state; - - const scene = scenes.find(isSceneActive); - - if (!scene) { - throw new Error('Could not find active scene'); - } - - return { - layout, - navigation, - position, - scenes, - scene, - index: scene.index, - }; -} - -function isSceneNotStale(scene: Scene) { - return !scene.isStale; -} - -function filterStale(scenes: Scene[]) { - const filtered = scenes.filter(isSceneNotStale); - if (filtered.length === scenes.length) { - return scenes; - } - return filtered; -} - -function isSceneActive(scene: Scene) { - return scene.isActive; -} - -const styles = StyleSheet.create({ - main: { - flex: 1, - }, -}); - -export default Transitioner; diff --git a/src/views/__tests__/ScenesReducer.test.tsx b/src/views/__tests__/ScenesReducer.test.tsx deleted file mode 100644 index 89c272634..000000000 --- a/src/views/__tests__/ScenesReducer.test.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import ScenesReducer from '../ScenesReducer'; -import { Scene, NavigationState, SceneDescriptor } from '../../types'; - -const MOCK_DESCRIPTOR: SceneDescriptor = {} as any; - -/** - * Simulate scenes transtion with changes of navigation states. - */ -function testTransition(states: string[][]) { - let descriptors = states - .reduce((acc, state) => acc.concat(state), [] as string[]) - .reduce( - (acc, key) => { - acc[key] = MOCK_DESCRIPTOR; - return acc; - }, - {} as { [key: string]: SceneDescriptor } - ); - const routes = states.map((keys, i) => ({ - key: String(i), - index: keys.length - 1, - routes: keys.map(key => ({ key, routeName: '' })), - isTransitioning: false, - })); - - let scenes: Scene[] = []; - let prevState: NavigationState | null = null; - routes.forEach((nextState: NavigationState) => { - scenes = ScenesReducer(scenes, nextState, prevState, descriptors); - prevState = nextState; - }); - - return scenes; -} - -describe('ScenesReducer', () => { - it('gets initial scenes', () => { - const scenes = testTransition([['1', '2']]); - - expect(scenes).toEqual([ - { - index: 0, - isActive: false, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_1', - route: { - key: '1', - routeName: '', - }, - }, - { - index: 1, - isActive: true, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_2', - route: { - key: '2', - routeName: '', - }, - }, - ]); - }); - - it('pushes new scenes', () => { - // Transition from ['1', '2'] to ['1', '2', '3']. - const scenes = testTransition([['1', '2'], ['1', '2', '3']]); - - expect(scenes).toEqual([ - { - index: 0, - isActive: false, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_1', - route: { - key: '1', - routeName: '', - }, - }, - { - index: 1, - isActive: false, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_2', - route: { - key: '2', - routeName: '', - }, - }, - { - index: 2, - isActive: true, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_3', - route: { - key: '3', - routeName: '', - }, - }, - ]); - }); - - it('gets active scene when index changes', () => { - const state1 = { - key: '0', - index: 0, - routes: [{ key: '1', routeName: '' }], - isTransitioning: false, - }; - - const state2 = { - key: '0', - index: 1, - routes: [{ key: '1', routeName: '' }, { key: '2', routeName: '' }], - isTransitioning: false, - }; - - const scenes1 = ScenesReducer([], state1, null, {}); - const scenes2 = ScenesReducer(scenes1, state2, state1, {}); - const route = scenes2.find(scene => scene.isActive)!.route; - expect(route).toEqual({ key: '2', routeName: '' }); - }); - - it('gets same scenes', () => { - const state1 = { - key: '0', - index: 1, - routes: [{ key: '1', routeName: '' }, { key: '2', routeName: '' }], - isTransitioning: false, - }; - - const state2 = { - key: '0', - index: 1, - routes: [{ key: '1', routeName: '' }, { key: '2', routeName: '' }], - isTransitioning: false, - }; - - const scenes1 = ScenesReducer([], state1, null, {}); - const scenes2 = ScenesReducer(scenes1, state2, state1, {}); - expect(scenes1).toBe(scenes2); - }); - - it('gets different scenes when keys are different', () => { - const state1 = { - key: '0', - index: 1, - routes: [{ key: '1', routeName: '' }, { key: '2', routeName: '' }], - isTransitioning: false, - }; - - const state2 = { - key: '0', - index: 1, - routes: [{ key: '2', routeName: '' }, { key: '1', routeName: '' }], - isTransitioning: false, - }; - - const descriptors = { 1: {}, 2: {} } as any; - - const scenes1 = ScenesReducer([], state1, null, descriptors); - const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors); - expect(scenes1).not.toBe(scenes2); - }); - - it('gets different scenes when routes are different', () => { - const state1 = { - key: '0', - index: 1, - routes: [ - { key: '1', x: 1, routeName: '' }, - { key: '2', x: 2, routeName: '' }, - ], - isTransitioning: false, - }; - - const state2 = { - key: '0', - index: 1, - routes: [ - { key: '1', x: 3, routeName: '' }, - { key: '2', x: 4, routeName: '' }, - ], - isTransitioning: false, - }; - - const descriptors = { 1: MOCK_DESCRIPTOR, 2: MOCK_DESCRIPTOR }; - - const scenes1 = ScenesReducer([], state1, null, descriptors); - const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors); - expect(scenes1).not.toBe(scenes2); - }); - - // NOTE(brentvatne): this currently throws a warning about invalid StackRouter state, - // which is correct because you can't have a state like state2 where the index is - // anything except the last route in the array of routes. - it('gets different scenes when state index changes', () => { - const state1 = { - key: '0', - index: 1, - routes: [ - { key: '1', x: 1, routeName: '' }, - { key: '2', x: 2, routeName: '' }, - ], - isTransitioning: false, - }; - - const state2 = { - key: '0', - index: 0, - routes: [ - { key: '1', x: 1, routeName: '' }, - { key: '2', x: 2, routeName: '' }, - ], - isTransitioning: false, - }; - - const descriptors = { 1: MOCK_DESCRIPTOR, 2: MOCK_DESCRIPTOR }; - const scenes1 = ScenesReducer([], state1, null, descriptors); - const scenes2 = ScenesReducer(scenes1, state2, state1, descriptors); - expect(scenes1).not.toBe(scenes2); - }); - - it('pops scenes', () => { - // Transition from ['1', '2', '3'] to ['1', '2']. - const scenes = testTransition([['1', '2', '3'], ['1', '2']]); - - expect(scenes).toEqual([ - { - index: 0, - isActive: false, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_1', - route: { - key: '1', - routeName: '', - }, - }, - { - index: 1, - isActive: true, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_2', - route: { - key: '2', - routeName: '', - }, - }, - { - index: 2, - isActive: false, - isStale: true, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_3', - route: { - key: '3', - routeName: '', - }, - }, - ]); - }); - - it('replaces scenes', () => { - const scenes = testTransition([['1', '2'], ['3']]); - - expect(scenes).toEqual([ - { - index: 0, - isActive: false, - isStale: true, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_1', - route: { - key: '1', - routeName: '', - }, - }, - { - index: 0, - isActive: true, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_3', - route: { - key: '3', - routeName: '', - }, - }, - { - index: 1, - isActive: false, - isStale: true, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_2', - route: { - key: '2', - routeName: '', - }, - }, - ]); - }); - - it('revives scenes', () => { - const scenes = testTransition([['1', '2'], ['3'], ['2']]); - - expect(scenes).toEqual([ - { - index: 0, - isActive: false, - isStale: true, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_1', - route: { - key: '1', - routeName: '', - }, - }, - { - index: 0, - isActive: true, - isStale: false, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_2', - route: { - key: '2', - routeName: '', - }, - }, - { - index: 0, - isActive: false, - isStale: true, - descriptor: MOCK_DESCRIPTOR, - key: 'scene_3', - route: { - key: '3', - routeName: '', - }, - }, - ]); - }); -}); diff --git a/src/views/__tests__/Transitioner.test.tsx b/src/views/__tests__/Transitioner.test.tsx deleted file mode 100644 index d45f5a29b..000000000 --- a/src/views/__tests__/Transitioner.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint react/display-name:0 */ -import * as React from 'react'; -import renderer from 'react-test-renderer'; -import Transitioner from '../Transitioner'; - -describe('Transitioner', () => { - // TODO: why does this fail here but not when it was part of react-navigation repo? - it.skip('should not trigger onTransitionStart and onTransitionEnd when route params are changed', () => { - const onTransitionStartCallback = jest.fn(); - const onTransitionEndCallback = jest.fn(); - - const transitionerProps = { - configureTransition: () => ({}), - navigation: { - state: { - key: '0', - index: 0, - routes: [ - { key: '1', routeName: 'Foo' }, - { key: '2', routeName: 'Bar' }, - ], - }, - goBack: () => false, - dispatch: () => false, - setParams: () => false, - navigate: () => false, - isFocused: () => false, - dangerouslyGetParent: () => undefined, - getParam: jest.fn(), - addListener: jest.fn(), - }, - render: () =>
, - onTransitionStart: onTransitionStartCallback, - onTransitionEnd: onTransitionEndCallback, - }; - - const nextTransitionerProps = { - ...transitionerProps, - navigation: { - ...transitionerProps.navigation, - state: { - key: '0', - index: 0, - routes: [ - { key: '1', routeName: 'Foo', params: { name: 'Zoom' } }, - { key: '2', routeName: 'Bar' }, - ], - }, - }, - }; - const component = renderer.create( - - ); - component.update( - - ); - expect(onTransitionStartCallback).not.toBeCalled(); - expect(onTransitionEndCallback).not.toBeCalled(); - }); -}); diff --git a/types/@react-navigation/core.d.ts b/types/@react-navigation/core.d.ts index d3561b8f0..e56549624 100644 --- a/types/@react-navigation/core.d.ts +++ b/types/@react-navigation/core.d.ts @@ -5,6 +5,12 @@ declare module '@react-navigation/core' { completeTransition( options?: T ): { type: string } & T; + push( + options?: T + ): { type: string } & T; + pop( + options?: T + ): { type: string } & T; }; export const NavigationActions: { diff --git a/yarn.lock b/yarn.lock index b3e5f5efc..9ce58601c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7030,7 +7030,7 @@ react-is@^16.5.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== -react-native-gesture-handler@^1.1.0: +react-native-gesture-handler@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-1.2.1.tgz#9c48fb1ab13d29cece24bbb77b1e847eebf27a2b" integrity sha512-c1+L72Vjc/bwHKcIJ8a2/88SW9l3/axcAIpg3zB1qTzwdCxHZJeQn6d58cQXHPepxFBbgfTCo60B7SipSfo+zw== @@ -7039,6 +7039,11 @@ react-native-gesture-handler@^1.1.0: invariant "^2.2.2" prop-types "^15.5.10" +react-native-reanimated@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-1.0.1.tgz#5ecb6a2f6dad0351077ac9b771ca943b7ad6feda" + integrity sha512-RENoo6/sJc3FApP7vJ1Js7WyDuTVh97bbr5aMjJyw3kqpR2/JDHyL/dQFfOvSSAc+VjitpR9/CfPPad7tLRiIA== + react-native-safe-area-view@^0.13.0: version "0.13.1" resolved "https://registry.yarnpkg.com/react-native-safe-area-view/-/react-native-safe-area-view-0.13.1.tgz#834bbb6d22f76a7ff07de56725ee5667ba1386b0"