refactor: rewrite based on reanimated

This commit is contained in:
satyajit.happy
2019-05-25 00:58:11 +02:00
parent 5039391dc5
commit 06b3867e17
48 changed files with 2266 additions and 4649 deletions

View File

@@ -7,6 +7,7 @@
[
'react-navigation-stack',
'react-native-gesture-handler',
'react-native-reanimated',
'react-native-vector-icons',
],
},

View File

@@ -36,8 +36,8 @@ import { useScreens } from 'react-native-screens';
I18nManager.forceRTL(false);
const data = [
{ component: SimpleStack, title: 'Simple', routeName: 'SimpleStack' },
{ component: HeaderPreset, title: 'UIKit Preset', routeName: 'UIKit' },
{ component: SimpleStack, title: 'Wipe Preset', routeName: 'SimpleStack' },
{ component: ImageStack, title: 'Image', routeName: 'ImageStack' },
{ component: ModalStack, title: 'Modal', routeName: 'ModalStack' },
{ component: FullScreen, title: 'Full Screen', routeName: 'FullScreen' },
@@ -76,11 +76,6 @@ const data = [
title: 'Header background (fade transition)',
routeName: 'HeaderBackgroundFade',
},
{
component: HeaderBackgroundTranslate,
title: 'Header background (translate transition)',
routeName: 'HeaderBackgroundTranslate',
},
];
// Cache images

View File

@@ -1,6 +1,11 @@
import * as React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import { createStackNavigator } from 'react-navigation-stack';
import Animated from 'react-native-reanimated';
import {
createStackNavigator,
TransitionPresets,
HeaderStyleInterpolators,
} from 'react-navigation-stack';
function createHeaderBackgroundExample(options = {}) {
return createStackNavigator(
@@ -19,7 +24,7 @@ function createHeaderBackgroundExample(options = {}) {
navigationOptions: {
headerTitle: 'Login Screen',
headerTintColor: '#fff',
headerBackground: (
headerBackground: () => (
<View style={{ flex: 1, backgroundColor: '#FF0066' }} />
),
},
@@ -38,7 +43,7 @@ function createHeaderBackgroundExample(options = {}) {
navigationOptions: {
headerTitle: 'Games Screen',
headerTintColor: '#fff',
headerBackground: (
headerBackground: () => (
<View style={{ flex: 1, backgroundColor: '#3388FF' }} />
),
},
@@ -90,11 +95,11 @@ function createHeaderBackgroundExample(options = {}) {
);
}
export const HeaderBackgroundDefault = createHeaderBackgroundExample();
export const HeaderBackgroundTranslate = createHeaderBackgroundExample({
headerBackgroundTransitionPreset: 'translate',
});
export const HeaderBackgroundFade = createHeaderBackgroundExample({
headerBackgroundTransitionPreset: 'fade',
...TransitionPresets.SlideFromRightIOS,
headerStyleInterpolator: HeaderStyleInterpolators.forFade,
headerMode: 'float',
});
const styles = StyleSheet.create({

View File

@@ -1,7 +1,10 @@
import * as React from 'react';
import { Button, StatusBar } from 'react-native';
import { SafeAreaView } from '@react-navigation/native';
import { createStackNavigator } from 'react-navigation-stack';
import {
createStackNavigator,
TransitionPresets,
} from 'react-navigation-stack';
class HomeScreen extends React.Component {
static navigationOptions = {
@@ -100,9 +103,7 @@ const StackWithHeaderPreset = createStackNavigator(
ScreenWithNoHeader: ScreenWithNoHeader,
ScreenWithLongTitle: ScreenWithLongTitle,
},
{
headerTransitionPreset: 'uikit',
}
TransitionPresets.SlideFromRightIOS
);
export default StackWithHeaderPreset;

View File

@@ -7,7 +7,7 @@ class ListScreen extends React.Component {
static navigationOptions = ({ navigation }) => ({
title: 'Image list',
headerBackTitle: 'Back',
headerLeft: (
headerLeft: () => (
<Button title="Back" onPress={() => navigation.navigate('Home')} />
),
});

View File

@@ -1,7 +1,10 @@
import * as React from 'react';
import { Dimensions, Button, View, Text } from 'react-native';
import { withNavigation } from '@react-navigation/core';
import { createStackNavigator } from 'react-navigation-stack';
import {
createStackNavigator,
TransitionPresets,
} from 'react-navigation-stack';
const Buttons = withNavigation(props => (
<React.Fragment>
@@ -148,11 +151,7 @@ export default createStackNavigator(
},
{
initialRouteName: 'List',
// these are the defaults
cardShadowEnabled: true,
cardOverlayEnabled: false,
// headerTransitionPreset: 'uikit',
headerMode: 'screen',
...TransitionPresets.WipeFromBottomAndroid,
}
);

View File

@@ -1,6 +1,9 @@
import * as React from 'react';
import { Animated, Button, Easing, View, Text } from 'react-native';
import { Button, View, Text } from 'react-native';
import { createStackNavigator } from 'react-navigation-stack';
import Animated from 'react-native-reanimated';
const { interpolate, multiply, cond } = Animated;
class ListScreen extends React.Component {
render() {
@@ -78,23 +81,21 @@ export default createStackNavigator(
navigationOptions: {
gesturesEnabled: false,
},
transitionConfig: () => ({
transitionSpec: {
duration: 300,
easing: Easing.inOut(Easing.ease),
timing: Animated.timing,
},
screenInterpolator: sceneProps => {
const { position, scene } = sceneProps;
const { index } = scene;
cardStyleInterpolator: ({ progress: { current }, closing }) => {
const opacity = cond(
closing,
current,
interpolate(current, {
inputRange: [0, 0.5, 0.9, 1],
outputRange: [0, 0.25, 0.7, 1],
})
);
const opacity = position.interpolate({
inputRange: [index - 1, index],
outputRange: [0, 1],
});
return { opacity };
},
}),
return {
cardStyle: {
opacity,
},
};
},
}
);

View File

@@ -41,6 +41,9 @@
"url": "https://github.com/react-navigation/react-navigation-stack/issues"
},
"homepage": "https://github.com/react-navigation/react-navigation-stack#readme",
"dependencies": {
"react-native-safe-area-view": "^0.14.4"
},
"devDependencies": {
"@babel/core": "^7.4.4",
"@commitlint/config-conventional": "^7.5.0",
@@ -63,7 +66,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 +80,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": {

View File

@@ -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({
progress: { current, next },
layouts: { screen },
}: CardInterpolationProps): CardInterpolatedStyle {
const translateFocused = interpolate(current, {
inputRange: [0, 1],
outputRange: [screen.width, 0],
});
const translateUnfocused = next
? interpolate(next, {
inputRange: [0, 1],
outputRange: [0, multiply(screen.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({
progress: { current },
layouts: { screen },
}: CardInterpolationProps): CardInterpolatedStyle {
const translateY = interpolate(current, {
inputRange: [0, 1],
outputRange: [screen.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({
progress: { current },
layouts: { screen },
closing,
}: CardInterpolationProps): CardInterpolatedStyle {
const translateY = interpolate(current, {
inputRange: [0, 1],
outputRange: [multiply(screen.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({
progress: { current, next },
layouts: { screen },
}: CardInterpolationProps): CardInterpolatedStyle {
const containerTranslateY = interpolate(current, {
inputRange: [0, 1],
outputRange: [screen.height, 0],
});
const cardTranslateYFocused = interpolate(current, {
inputRange: [0, 1],
outputRange: [multiply(screen.height, 95.9 / 100, -1), 0],
});
const cardTranslateYUnfocused = next
? interpolate(next, {
inputRange: [0, 1],
outputRange: [0, multiply(screen.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 },
};
}

View File

@@ -0,0 +1,100 @@
import Animated from 'react-native-reanimated';
import { HeaderInterpolationProps, HeaderInterpolatedStyle } from '../types';
const { interpolate, add } = Animated;
export function forUIKit({
progress: { current, next },
layouts,
}: HeaderInterpolationProps): HeaderInterpolatedStyle {
const defaultOffset = 100;
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 leftLabelOffset = layouts.leftLabel
? (layouts.screen.width - layouts.leftLabel.width) / 2 - leftSpacing
: defaultOffset;
const titleLeftOffset = layouts.title
? (layouts.screen.width - layouts.title.width) / 2 - leftSpacing
: defaultOffset;
// 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],
}),
},
leftLabelStyle: {
transform: [
{
translateX: interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [leftLabelOffset, 0, -rightOffset],
}),
},
],
},
rightButtonStyle: {
opacity: interpolate(progress, {
inputRange: [0.3, 1, 1.5],
outputRange: [0, 1, 0],
}),
},
titleStyle: {
opacity: interpolate(progress, {
inputRange: [0, 0.4, 1, 1.5],
outputRange: [0, 0.1, 1, 0],
}),
transform: [
{
translateX: interpolate(progress, {
inputRange: [0.5, 1, 2],
outputRange: [rightOffset, 0, -titleLeftOffset],
}),
},
],
},
backgroundStyle: {
transform: [
{
translateX: interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [layouts.screen.width, 0, -layouts.screen.width],
}),
},
],
},
};
}
export function forFade({
progress: { 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 },
rightButtonStyle: { opacity },
titleStyle: { opacity },
backgroundStyle: { opacity },
};
}
export function forNoAnimation(): HeaderInterpolatedStyle {
return {};
}

View File

@@ -0,0 +1,69 @@
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',
transitionSpec: {
open: TransitionIOSSpec,
close: TransitionIOSSpec,
},
cardStyleInterpolator: forHorizontalIOS,
headerStyleInterpolator: forUIKit,
};
// Standard iOS navigation transition for modals
export const ModalSlideFromBottomIOS: TransitionPreset = {
direction: 'vertical',
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',
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',
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,
});

View File

@@ -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),
},
};

View File

@@ -1,4 +1,6 @@
import { Platform } from 'react-native';
import * as CardStyleInterpolators from './TransitionConfigs/CardStyleInterpolators';
import * as HeaderStyleInterpolators from './TransitionConfigs/HeaderStyleInterpolators';
import * as TransitionPresets from './TransitionConfigs/TransitionPresets';
/**
* Navigators
@@ -7,35 +9,25 @@ export {
default as createStackNavigator,
} from './navigators/createStackNavigator';
export const Assets = Platform.select({
ios: [
require('./views/assets/back-icon.png'),
require('./views/assets/back-icon-mask.png'),
],
default: [require('./views/assets/back-icon.png')],
});
export const Assets = [
require('./views/assets/back-icon.png'),
require('./views/assets/back-icon-mask.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 HeaderBackButton } from './views/Header/HeaderBackButton';
/**
* Transition presets
*/
export { CardStyleInterpolators, HeaderStyleInterpolators, TransitionPresets };
/**
* Utilities
*/
export { default as StackGestureContext } from './utils/StackGestureContext';

View File

@@ -1,33 +0,0 @@
/**
* Navigators
*/
export {
default as createStackNavigator,
} 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';

View File

@@ -63,7 +63,7 @@ describe('StackNavigator', () => {
Home: {
screen: HomeScreen,
navigationOptions: {
headerRight: <View />,
headerRight: () => <View />,
},
},
});
@@ -89,7 +89,7 @@ describe('StackNavigator', () => {
class A extends React.Component {
static navigationOptions = {
headerRight: <TestComponentWithNavigation onPress={spy} />,
headerRight: () => <TestComponentWithNavigation onPress={spy} />,
};
render() {

View File

@@ -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(

View File

@@ -1,20 +1,16 @@
import { Animated, StyleProp, TextStyle, ViewStyle } from 'react-native';
import { SafeAreaView } from '@react-navigation/native';
import {
StyleProp,
TextStyle,
ViewStyle,
LayoutChangeEvent,
} from 'react-native';
import Animated from 'react-native-reanimated';
export type Route = {
key: string;
routeName: string;
};
export type Scene = {
key: string;
index: number;
isStale: boolean;
isActive: boolean;
route: Route;
descriptor: SceneDescriptor;
};
export type NavigationEventName =
| 'willFocus'
| 'didFocus'
@@ -25,7 +21,10 @@ export type NavigationState = {
key: string;
index: number;
routes: Route[];
isTransitioning?: boolean;
transitions: {
pushing: string[];
popping: string[];
};
params?: { [key: string]: unknown };
};
@@ -45,92 +44,67 @@ export type NavigationProp<RouteName = string, Params = object> = {
dangerouslyGetParent(): NavigationProp | undefined;
};
export type HeaderMode = 'float' | 'screen';
export type Layout = { width: number; height: number };
export type HeaderLayoutPreset = 'left' | 'center';
export type GestureDirection = 'horizontal' | 'vertical';
export type HeaderTransitionPreset = 'fade-in-place' | 'uikit';
export type HeaderMode = 'float' | 'screen' | 'none';
export type HeaderBackgroundTransitionPreset = 'translate' | 'fade';
export type HeaderProps = {
mode: HeaderMode;
position: Animated.Value;
navigation: NavigationProp;
layout: TransitionerLayout;
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 HeaderScene<T> = {
route: T;
descriptor: SceneDescriptor;
progress: {
current: Animated.Node<number>;
next?: Animated.Node<number>;
previous?: Animated.Node<number>;
};
};
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;
export type HeaderOptions = {
headerTitle?: string;
headerTitleStyle?: StyleProp<TextStyle>;
headerTitleContainerStyle?: StyleProp<ViewStyle>;
headerTintColor?: string;
headerTitleAllowFontScaling?: boolean;
headerBackAllowFontScaling?: boolean;
headerBackTitle?: string;
headerBackTitleStyle?: StyleProp<TextStyle>;
headerTruncatedBackTitle?: string;
headerLeft?: React.FunctionComponent<HeaderBackbuttonProps>;
headerLeft?: (props: HeaderBackButtonProps) => React.ReactNode;
headerLeftContainerStyle?: StyleProp<ViewStyle>;
headerRight?: (() => React.ReactNode) | React.ReactNode;
headerRight?: () => React.ReactNode;
headerRightContainerStyle?: StyleProp<ViewStyle>;
headerBackImage?: React.FunctionComponent<{
tintColor: string;
title?: string | null;
}>;
headerBackImage?: HeaderBackButtonProps['backImage'];
headerPressColorAndroid?: string;
headerBackground?: string;
headerTransparent?: boolean;
headerBackground?: () => React.ReactNode;
headerStyle?: StyleProp<ViewStyle>;
headerForceInset?: React.ComponentProps<typeof SafeAreaView>['forceInset'];
headerStatusBarHeight?: number;
};
export type HeaderProps = {
mode: 'float' | 'screen';
layout: Layout;
scene: HeaderScene<Route>;
previous?: HeaderScene<Route>;
navigation: NavigationProp;
styleInterpolator: HeaderStyleInterpolator;
};
export type NavigationStackOptions = HeaderOptions & {
title?: string;
header?: null | ((props: HeaderProps) => React.ReactNode);
gesturesEnabled?: boolean;
gestureDirection?: 'inverted' | 'normal';
gestureResponseDistance?: {
vertical: number;
horizontal: number;
vertical?: number;
horizontal?: number;
};
disableKeyboardHandling?: boolean;
};
export type NavigationConfig = {
export type NavigationConfig = TransitionPreset & {
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;
transparentCard?: boolean;
};
export type SceneDescriptor = {
@@ -140,58 +114,20 @@ export type SceneDescriptor = {
getComponent(): React.ComponentType;
};
export type HeaderBackbuttonProps = {
export type HeaderBackButtonProps = {
disabled?: boolean;
onPress: () => void;
onPress?: () => void;
pressColorAndroid?: string;
tintColor: string;
backImage?: NavigationStackOptions['headerBackImage'];
title?: string | null;
truncatedTitle?: string | null;
backTitleVisible?: boolean;
backImage?: (props: { tintColor: string; label?: string }) => React.ReactNode;
tintColor?: string;
label?: string;
truncatedLabel?: string;
labelVisible?: boolean;
labelStyle?: React.ComponentProps<typeof Animated.Text>['style'];
allowFontScaling?: boolean;
titleStyle?: StyleProp<TextStyle>;
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 SceneInterpolator = (props: SceneInterpolatorProps) => any;
export type TransitionerLayout = {
height: Animated.Value;
width: Animated.Value;
initHeight: number;
initWidth: number;
isMeasured: boolean;
};
export type TransitionProps = {
layout: TransitionerLayout;
navigation: NavigationProp;
position: Animated.Value;
scenes: Scene[];
scene: Scene;
index: number;
};
export type TransitionConfig = {
transitionSpec: {
timing: Function;
};
screenInterpolator: SceneInterpolator;
containerStyle?: StyleProp<ViewStyle>;
onLabelLayout?: (e: LayoutChangeEvent) => void;
screenLayout?: Layout;
titleLayout?: Layout;
};
export type Screen = React.ComponentType<any> & {
@@ -199,3 +135,76 @@ export type Screen = React.ComponentType<any> & {
[key: string]: any;
};
};
export type SpringConfig = {
damping: number;
mass: number;
stiffness: number;
restSpeedThreshold: number;
restDisplacementThreshold: number;
overshootClamping: boolean;
};
export type TimingConfig = {
duration: number;
easing: Animated.EasingFunction;
};
export type TransitionSpec =
| { timing: 'spring'; config: SpringConfig }
| { timing: 'timing'; config: TimingConfig };
export type CardInterpolationProps = {
progress: {
current: Animated.Node<number>;
next?: Animated.Node<number>;
};
closing: Animated.Node<0 | 1>;
layouts: {
screen: Layout;
};
};
export type CardInterpolatedStyle = {
containerStyle?: any;
cardStyle?: any;
overlayStyle?: any;
};
export type CardStyleInterpolator = (
props: CardInterpolationProps
) => CardInterpolatedStyle;
export type HeaderInterpolationProps = {
progress: {
current: Animated.Node<number>;
next?: Animated.Node<number>;
};
layouts: {
screen: Layout;
title?: Layout;
leftLabel?: Layout;
};
};
export type HeaderInterpolatedStyle = {
leftLabelStyle?: any;
leftButtonStyle?: any;
rightButtonStyle?: any;
titleStyle?: any;
backgroundStyle?: any;
};
export type HeaderStyleInterpolator = (
props: HeaderInterpolationProps
) => HeaderInterpolatedStyle;
export type TransitionPreset = {
direction: GestureDirection;
transitionSpec: {
open: TransitionSpec;
close: TransitionSpec;
};
cardStyleInterpolator: CardStyleInterpolator;
headerStyleInterpolator: HeaderStyleInterpolator;
};

View File

@@ -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;
};

View File

@@ -1,4 +1,6 @@
import * as React from 'react';
import { PanGestureHandler } from 'react-native-gesture-handler';
export default React.createContext<React.Ref<PanGestureHandler> | null>(null);
export default React.createContext<React.Ref<PanGestureHandler> | undefined>(
undefined
);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1,33 @@
export default function memoize<Result, Deps extends ReadonlyArray<any>>(
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;
};
}

View File

@@ -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;

View File

@@ -1,16 +0,0 @@
import * as React from 'react';
type Props = {
tintColor: string;
};
export default function BackButton({ tintColor }: Props) {
return (
<svg width="24px" height="24px" viewBox="0 0 24 24">
<path
fill={tintColor}
d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"
/>
</svg>
);
}

View File

@@ -1,820 +1,49 @@
import * as React from 'react';
import { StackActions } from '@react-navigation/core';
import HeaderSegment from './HeaderSegment';
import { HeaderProps } from '../../types';
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<ViewStyle>;
};
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<Props, State> {
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 (
<HeaderTitleComponent
onLayout={onLayout}
allowFontScaling={!!allowFontScaling}
style={[
color ? { color } : null,
layoutPreset === 'center'
? // eslint-disable-next-line react-native/no-inline-styles
{ textAlign: 'center' }
: // eslint-disable-next-line react-native/no-inline-styles
{ textAlign: 'left' },
titleStyle,
]}
>
{titleString}
</HeaderTitleComponent>
);
};
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 (
<RenderedLeftComponent
onPress={goBack}
pressColorAndroid={options.headerPressColorAndroid}
tintColor={options.headerTintColor}
backImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
backTitleVisible={this.props.backTitleVisible}
allowFontScaling={options.headerBackAllowFontScaling}
titleStyle={options.headerBackTitleStyle}
layoutPreset={this.props.layoutPreset}
width={width}
scene={props.scene}
/>
);
};
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 (
<ModularHeaderBackButton
onPress={goBack}
ButtonContainerComponent={ButtonContainerComponent}
LabelContainerComponent={LabelContainerComponent}
backTitleVisible={this.props.backTitleVisible}
pressColorAndroid={options.headerPressColorAndroid}
tintColor={options.headerTintColor}
backImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
titleStyle={options.headerBackTitleStyle}
layoutPreset={this.props.layoutPreset}
width={width}
scene={props.scene}
/>
);
};
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<ViewStyle>;
}
) => {
const { layoutPreset, transitionPreset } = this.props;
let style: StyleProp<ViewStyle> = [
{ 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 }) => (
<Animated.View
style={[buttonStyleInterpolator({ ...this.props, ...props })]}
>
{children}
</Animated.View>
);
const LabelContainer = ({ children }: { children: React.ReactNode }) => (
<Animated.View
style={[labelStyleInterpolator({ ...this.props, ...props })]}
>
{children}
</Animated.View>
);
const subView = renderer(
props,
ButtonContainer as any,
LabelContainer as any
);
if (subView === null) {
return subView;
}
const pointerEvents = offset !== 0 || isStale ? 'none' : 'box-none';
return (
<View
key={`${name}_${key}`}
pointerEvents={pointerEvents}
style={[styles.item, styles[name], props.style]}
>
{subView}
</View>
);
};
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 (
<Animated.View
pointerEvents={pointerEvents}
key={`${name}_${key}`}
style={[
styles.item,
styles[name],
props.style,
styleInterpolator({
...this.props,
...props,
}),
]}
>
{subView}
</Animated.View>
);
};
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 (
<View {...wrapperProps}>
{title}
{left}
{right}
</View>
);
} else {
return (
<MaskedViewIOS
{...wrapperProps}
maskElement={
<View style={styles.iconMaskContainer}>
<Image
source={require('../assets/back-icon-mask.png')}
style={styles.iconMask}
/>
<View style={styles.iconMaskFillerRect} />
</View>
}
>
{title}
{left}
{right}
</MaskedViewIOS>
);
}
};
export default class Header extends React.PureComponent<HeaderProps> {
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;
scene,
previous,
layout,
navigation,
styleInterpolator,
} = this.props;
const { options } = scene.descriptor;
const title =
options.headerTitle !== undefined ? options.headerTitle : options.title;
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');
let leftLabel;
if (options.headerBackTitle !== undefined) {
leftLabel = options.headerBackTitle;
} else {
if (previous) {
const opts = previous.descriptor.options;
leftLabel =
opts.headerTitle !== undefined ? opts.headerTitle : opts.title;
}
}
// 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 (
<Animated.View
style={[
this.props.layoutInterpolator(this.props),
Platform.OS === 'ios' && !options.headerTransparent
? {
backgroundColor:
safeHeaderStyle.backgroundColor || DEFAULT_BACKGROUND_COLOR,
}
: null,
]}
>
<SafeAreaView forceInset={forceInset} style={containerStyles}>
{background}
<View style={styles.flexOne}>{appBar}</View>
</SafeAreaView>
</Animated.View>
<HeaderSegment
{...options}
layout={layout}
scene={scene}
title={title}
leftLabel={leftLabel}
onGoBack={
// TODO: use isFirstRouteInParent
previous
? () =>
navigation.dispatch(StackActions.pop({ key: scene.route.key }))
: undefined
}
styleInterpolator={styleInterpolator}
/>
);
}
}
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);

View File

@@ -2,180 +2,208 @@ 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 { HeaderBackButtonProps } from '../../types';
import defaultBackImage from '../assets/back-icon.png';
import BackButtonWeb from './BackButtonWeb';
import { HeaderBackbuttonProps } from '../../types';
type State = {
initialTextWidth?: number;
type Props = HeaderBackButtonProps & {
tintColor: string;
};
class HeaderBackButton extends React.PureComponent<
HeaderBackbuttonProps,
State
> {
type State = {
initialLabelWidth?: number;
};
class HeaderBackButton extends React.Component<Props, State> {
static defaultProps = {
pressColorAndroid: 'rgba(0, 0, 0, .32)',
tintColor: Platform.select({
ios: '#037aff',
web: '#5f6368',
}),
truncatedTitle: 'Back',
backImage: Platform.select({
web: BackButtonWeb,
}),
labelVisible: Platform.OS === 'ios',
truncatedLabel: 'Back',
};
state: State = {};
private handleTextLayout = (e: LayoutChangeEvent) => {
if (this.state.initialTextWidth) {
private handleLabelLayout = (e: LayoutChangeEvent) => {
const { onLabelLayout } = this.props;
onLabelLayout && onLabelLayout(e);
if (this.state.initialLabelWidth) {
return;
}
this.setState({
initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
initialLabelWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
});
};
private renderBackImage() {
const { backImage, backTitleVisible, tintColor } = this.props;
const { backImage, labelVisible, tintColor } = this.props;
let title = this.getTitleText();
let label = this.getLabelText();
if (React.isValidElement(backImage)) {
return backImage;
} else if (backImage) {
const BackImage = backImage;
return <BackImage tintColor={tintColor} title={title} />;
if (backImage) {
return backImage({ tintColor, label });
} else {
return (
<Image
style={[
styles.icon,
!!backTitleVisible && styles.iconWithTitle,
!!labelVisible && styles.iconWithTitle,
!!tintColor && { tintColor },
]}
source={defaultBackImage}
source={require('../assets/back-icon.png')}
fadeDuration={0}
/>
);
}
}
private getTitleText = () => {
const { width, title, truncatedTitle } = this.props;
private getLabelText = () => {
const { titleLayout, screenLayout, label, truncatedLabel } = this.props;
let { initialTextWidth } = this.state;
let { initialLabelWidth: initialLabelWidth } = this.state;
if (title === null) {
return null;
} else if (!title) {
return truncatedTitle;
} else if (initialTextWidth && width && initialTextWidth > width) {
return truncatedTitle;
if (!label) {
return truncatedLabel;
} else if (
initialLabelWidth &&
titleLayout &&
screenLayout &&
(screenLayout.width - titleLayout.width) / 2 < initialLabelWidth + 26
) {
return truncatedLabel;
} else {
return title;
return label;
}
};
private maybeRenderTitle() {
const {
allowFontScaling,
backTitleVisible,
titleStyle,
labelVisible,
backImage,
labelStyle,
tintColor,
screenLayout,
} = this.props;
let backTitleText = this.getTitleText();
if (!backTitleVisible || backTitleText === null) {
let leftLabelText = this.getLabelText();
if (!labelVisible || leftLabelText === undefined) {
return null;
}
return (
<Text
const title = (
<Animated.Text
accessible={false}
onLayout={this.handleTextLayout}
style={[styles.title, !!tintColor && { color: tintColor }, titleStyle]}
onLayout={this.handleLabelLayout}
style={[
styles.title,
screenLayout ? { marginRight: screenLayout.width / 2 } : null,
tintColor ? { color: tintColor } : null,
labelStyle,
]}
numberOfLines={1}
allowFontScaling={!!allowFontScaling}
>
{this.getTitleText()}
</Text>
{this.getLabelText()}
</Animated.Text>
);
if (backImage) {
return title;
}
return (
<MaskedViewIOS
maskElement={
<View style={styles.iconMaskContainer}>
<Image
source={require('../assets/back-icon-mask.png')}
style={styles.iconMask}
/>
<View style={styles.iconMaskFillerRect} />
</View>
}
>
{title}
</MaskedViewIOS>
);
}
render() {
const { onPress, pressColorAndroid, title, disabled } = this.props;
private handlePress = () =>
this.props.onPress && requestAnimationFrame(this.props.onPress);
let button = (
render() {
const { pressColorAndroid, label, disabled } = this.props;
return (
<TouchableItem
disabled={disabled}
accessible
accessibilityRole="button"
accessibilityComponentType="button"
accessibilityLabel={title ? `${title}, back` : 'Go back'}
accessibilityLabel={
label && label !== 'Back' ? `${label}, back` : 'Go back'
}
accessibilityTraits="button"
testID="header-back"
delayPressIn={0}
onPress={disabled ? undefined : onPress}
onPress={disabled ? undefined : this.handlePress}
pressColor={pressColorAndroid}
style={[styles.container, disabled && styles.disabled]}
hitSlop={Platform.select({
ios: undefined,
default: { top: 8, right: 8, bottom: 8, left: 8 },
})}
borderless
>
<View style={styles.container}>
<React.Fragment>
{this.renderBackImage()}
{this.maybeRenderTitle()}
</View>
</React.Fragment>
</TouchableItem>
);
if (Platform.OS === 'ios') {
return button;
} else {
return <View style={styles.androidButtonWrapper}>{button}</View>;
}
}
}
const styles = StyleSheet.create({
disabled: {
opacity: 0.5,
},
androidButtonWrapper: {
margin: 13,
backgroundColor: 'transparent',
...Platform.select({
web: {
marginLeft: 21,
},
default: {},
}),
},
container: {
alignItems: 'center',
flexDirection: 'row',
backgroundColor: 'transparent',
...Platform.select({
ios: null,
default: {
marginVertical: 3,
marginHorizontal: 11,
},
}),
},
disabled: {
opacity: 0.5,
},
title: {
fontSize: 17,
paddingRight: 10,
// Title and back title are a bit different width due to title being bold
// Adjusting the letterSpacing makes them coincide better
letterSpacing: 0.35,
},
icon: Platform.select({
ios: {
backgroundColor: 'transparent',
height: 21,
width: 13,
marginLeft: 9,
marginLeft: 8,
marginRight: 22,
marginVertical: 12,
resizeMode: 'contain',
@@ -186,7 +214,6 @@ const styles = StyleSheet.create({
width: 24,
margin: 3,
resizeMode: 'contain',
backgroundColor: 'transparent',
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }],
},
}),
@@ -196,6 +223,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;

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import { View, StyleSheet, Platform, ViewProps } from 'react-native';
type Props = ViewProps;
export default function HeaderBackground({ style, ...rest }: Props) {
return <View style={[styles.container, style]} {...rest} />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
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)`,
},
}),
},
});

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import {
View,
StyleSheet,
LayoutChangeEvent,
StyleProp,
ViewStyle,
} from 'react-native';
import { getDefaultHeaderHeight } from './HeaderSegment';
import {
Layout,
Route,
HeaderScene,
NavigationProp,
HeaderStyleInterpolator,
} from '../../types';
import Header from './Header';
type Props = {
mode: 'float' | 'screen';
layout: Layout;
scenes: HeaderScene<Route>[];
navigation: NavigationProp;
onLayout?: (e: LayoutChangeEvent) => void;
styleInterpolator: HeaderStyleInterpolator;
style?: StyleProp<ViewStyle>;
};
export default function HeaderContainer({
mode,
scenes,
layout,
navigation,
onLayout,
styleInterpolator,
style,
}: Props) {
const focusedRoute = navigation.state.routes[navigation.state.index];
return (
<View pointerEvents="box-none" style={style}>
{scenes.map((scene, i, self) => {
if (mode === 'screen' && i !== self.length - 1) {
return null;
}
const { options } = scene.descriptor;
const isFocused = focusedRoute.key === scene.route.key;
const props = {
mode,
layout,
scene,
previous: self[i - 1],
navigation: scene.descriptor.navigation,
styleInterpolator,
};
return (
<View
key={scene.route.key}
onLayout={onLayout}
pointerEvents="box-none"
accessibilityElementsHidden={!isFocused}
importantForAccessibility={
isFocused ? 'auto' : 'no-hide-descendants'
}
style={[
{ height: getDefaultHeaderHeight(layout) },
mode === 'float' ? StyleSheet.absoluteFill : null,
options.headerStyle,
]}
>
{options.header !== undefined ? (
options.header == null ? null : (
options.header(props)
)
) : (
<Header {...props} />
)}
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,256 @@
import * as React from 'react';
import { View, StyleSheet, LayoutChangeEvent, Platform } from 'react-native';
import Animated from 'react-native-reanimated';
import { getStatusBarHeight } from 'react-native-safe-area-view';
import HeaderTitle from './HeaderTitle';
import HeaderBackButton from './HeaderBackButton';
import HeaderBackground from './HeaderBackground';
import memoize from '../../utils/memoize';
import {
Layout,
HeaderStyleInterpolator,
Route,
HeaderBackButtonProps,
HeaderOptions,
HeaderScene,
} from '../../types';
export type Scene<T> = {
route: T;
progress: Animated.Node<number>;
};
type Props = HeaderOptions & {
layout: Layout;
onGoBack?: () => void;
title?: string;
leftLabel?: string;
scene: HeaderScene<Route>;
styleInterpolator: HeaderStyleInterpolator;
};
type State = {
titleLayout?: Layout;
leftLabelLayout?: Layout;
};
export const getDefaultHeaderHeight = (layout: Layout) => {
const isLandscape = layout.width > layout.height;
let headerHeight;
if (Platform.OS === 'ios') {
// @ts-ignore
if (isLandscape && !Platform.isPad) {
headerHeight = 32;
} else {
headerHeight = 44;
}
} else if (Platform.OS === 'android') {
headerHeight = 56;
} else {
headerHeight = 64;
}
return headerHeight + getStatusBarHeight(isLandscape);
};
export default class HeaderSegment extends React.Component<Props, State> {
static defaultProps = {
headerBackground: () => <HeaderBackground />,
};
state: State = {};
private handleTitleLayout = (e: LayoutChangeEvent) => {
const { height, width } = e.nativeEvent.layout;
const { titleLayout } = this.state;
if (
titleLayout &&
height === titleLayout.height &&
width === titleLayout.width
) {
return;
}
this.setState({ titleLayout: { height, width } });
};
private handleLeftLabelLayout = (e: LayoutChangeEvent) => {
const { height, width } = e.nativeEvent.layout;
const { leftLabelLayout } = this.state;
if (
leftLabelLayout &&
height === leftLabelLayout.height &&
width === leftLabelLayout.width
) {
return;
}
this.setState({ leftLabelLayout: { height, width } });
};
private getInterpolatedStyle = memoize(
(
styleInterpolator: HeaderStyleInterpolator,
layout: Layout,
current: Animated.Node<number>,
next: Animated.Node<number> | undefined,
titleLayout: Layout | undefined,
leftLabelLayout: Layout | undefined
) =>
styleInterpolator({
progress: {
current,
next,
},
layouts: {
screen: layout,
title: titleLayout,
leftLabel: leftLabelLayout,
},
})
);
render() {
const {
scene,
layout,
title: currentTitle,
leftLabel: previousTitle,
onGoBack,
headerLeft: left = (props: HeaderBackButtonProps) => (
<HeaderBackButton {...props} />
),
headerBackground,
headerStatusBarHeight,
headerRight: right,
headerBackImage: backImage,
headerBackTitle: leftLabel,
headerTruncatedBackTitle: truncatedLabel,
headerPressColorAndroid: pressColorAndroid,
headerBackAllowFontScaling: backAllowFontScaling,
headerTitleAllowFontScaling: titleAllowFontScaling,
headerTitleStyle: customTitleStyle,
headerBackTitleStyle: customLeftLabelStyle,
headerLeftContainerStyle: leftContainerStyle,
headerRightContainerStyle: rightContainerStyle,
styleInterpolator,
} = this.props;
const { leftLabelLayout, titleLayout } = this.state;
const {
titleStyle,
leftButtonStyle,
leftLabelStyle,
rightButtonStyle,
backgroundStyle,
} = this.getInterpolatedStyle(
styleInterpolator,
layout,
scene.progress.current,
scene.progress.next,
titleLayout,
previousTitle ? leftLabelLayout : undefined
);
return (
<React.Fragment>
{headerBackground ? (
<Animated.View
pointerEvents="none"
style={[StyleSheet.absoluteFill, backgroundStyle]}
>
{headerBackground()}
</Animated.View>
) : null}
<View
pointerEvents="none"
style={{
height:
headerStatusBarHeight !== undefined
? headerStatusBarHeight
: getStatusBarHeight(layout.width > layout.height),
}}
/>
<View pointerEvents="box-none" style={[styles.container]}>
{onGoBack ? (
<Animated.View
style={[styles.left, leftButtonStyle, leftContainerStyle]}
>
{left({
backImage,
pressColorAndroid,
allowFontScaling: backAllowFontScaling,
onPress: onGoBack,
label: leftLabel !== undefined ? leftLabel : previousTitle,
truncatedLabel,
labelStyle: [leftLabelStyle, customLeftLabelStyle],
onLabelLayout: this.handleLeftLabelLayout,
screenLayout: layout,
titleLayout,
})}
</Animated.View>
) : null}
{currentTitle ? (
<HeaderTitle
allowFontScaling={titleAllowFontScaling}
onLayout={this.handleTitleLayout}
style={[
styles.title,
Platform.select({
ios: null,
default: { left: onGoBack ? 72 : 16 },
}),
titleStyle,
customTitleStyle,
]}
>
{currentTitle}
</HeaderTitle>
) : null}
{right ? (
<Animated.View
style={[styles.right, rightButtonStyle, rightContainerStyle]}
>
{right()}
</Animated.View>
) : null}
</View>
</React.Fragment>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
left: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'flex-start',
},
right: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'flex-end',
},
title: Platform.select({
ios: {},
default: { position: 'absolute' },
}),
});

View File

@@ -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,
};

View File

@@ -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<typeof Animated.Text>) => (
<Animated.Text
numberOfLines={1}
{...rest}
style={[styles.title, style]}
accessibilityTraits="header"
/>
);
type Props = React.ComponentProps<typeof Animated.Text> & {
children: string;
};
export default function HeaderTitle({ style, ...rest }: Props) {
return <Animated.Text {...rest} style={[styles.title, style]} />;
}
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;

View File

@@ -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<Props, State> {
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 <BackImage tintColor={tintColor} />;
} else {
return (
<Image
style={[
styles.icon,
!!backTitleVisible && styles.iconWithTitle,
!!tintColor && { tintColor },
]}
source={defaultBackImage}
/>
);
}
}
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 (
<LabelContainerComponent>
<Text
accessible={false}
onLayout={this.onTextLayout}
style={[
styles.title,
!!tintColor && { color: tintColor },
titleStyle,
]}
numberOfLines={1}
>
{this.getTitleText()}
</Text>
</LabelContainerComponent>
);
}
render() {
const { onPress, title } = this.props;
const { ButtonContainerComponent } = this.props;
return (
<TouchableItem
accessibilityComponentType="button"
accessibilityLabel={title ? `${title}, back` : 'Go back'}
accessibilityTraits="button"
testID="header-back"
delayPressIn={0}
onPress={onPress}
style={styles.container}
borderless
>
<View style={styles.container}>
<ButtonContainerComponent>
{this.renderBackImage()}
</ButtonContainerComponent>
{this.maybeRenderTitle()}
</View>
</TouchableItem>
);
}
}
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;

View File

@@ -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;
}

View File

@@ -0,0 +1,501 @@
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, Layout } from '../../types';
import memoize from '../../utils/memoize';
import StackGestureContext from '../../utils/StackGestureContext';
type Props = ViewProps & {
closing?: boolean;
transparent?: boolean;
next?: Animated.Node<number>;
current: Animated.Value<number>;
layout: Layout;
direction: 'horizontal' | 'vertical';
onOpen: () => void;
onClose: () => void;
onTransitionStart?: (props: { closing: boolean }) => void;
onGestureBegin?: () => void;
onGestureCanceled?: () => void;
onGestureEnd?: () => void;
children: React.ReactNode;
animateIn: boolean;
gesturesEnabled: boolean;
gestureResponseDistance?: {
vertical?: number;
horizontal?: number;
};
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;
/**
* 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 {
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<Props> {
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<Binary>(TRUE);
private nextIsVisible = new Value<Binary | -1>(UNSET);
private isClosing = new Value<Binary>(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 isSwipeCancelled = 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<number>) => {
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),
call([this.isVisible], ([value]: ReadonlyArray<Binary>) => {
const { onTransitionStart } = this.props;
onTransitionStart && onTransitionStart({ closing: !value });
}),
]),
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<Binary>) => {
const isOpen = Boolean(value);
const { onOpen, onClose } = this.props;
if (isOpen) {
onOpen();
} else {
onClose();
}
}),
]),
]);
};
private translate = block([
onChange(
this.isClosing,
cond(this.isClosing, set(this.nextIsVisible, FALSE))
),
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),
])
),
onChange(
this.isSwiping,
call(
[this.isSwiping, this.isSwipeCancelled],
([isSwiping, isSwipeCancelled]: readonly Binary[]) => {
const {
onGestureBegin,
onGestureEnd,
onGestureCanceled,
} = this.props;
if (isSwiping === TRUE) {
onGestureBegin && onGestureBegin();
} else {
if (isSwipeCancelled === TRUE) {
onGestureCanceled && onGestureCanceled();
} else {
onGestureEnd && onGestureEnd();
}
}
}
)
),
// 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.isSwipeCancelled, FALSE),
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.isSwipeCancelled,
eq(this.gestureState, GestureState.CANCELLED)
),
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<number>,
next: Animated.Node<number> | undefined,
layout: Layout
) =>
styleInterpolator({
progress: {
current,
next,
},
closing: this.isClosing,
layouts: {
screen: layout,
},
})
);
private gestureActivationCriteria() {
const { layout, direction, gestureResponseDistance } = this.props;
// Doesn't make sense for a response distance of 0, so this works fine
const distance =
direction === 'vertical'
? (gestureResponseDistance && gestureResponseDistance.vertical) ||
GESTURE_RESPONSE_DISTANCE_VERTICAL
: (gestureResponseDistance && gestureResponseDistance.horizontal) ||
GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
if (direction === 'vertical') {
return {
maxDeltaX: 15,
minOffsetY: 5,
hitSlop: { bottom: -layout.height + distance },
};
} else {
return {
minOffsetX: 5,
maxDeltaY: 20,
hitSlop: { right: -layout.width + distance },
};
}
}
private gestureRef: React.Ref<PanGestureHandler> = React.createRef();
render() {
const {
transparent,
layout,
current,
next,
direction,
gesturesEnabled,
children,
styleInterpolator,
...rest
} = this.props;
const {
containerStyle,
cardStyle,
overlayStyle,
} = this.getInterpolatedStyle(styleInterpolator, current, next, layout);
const handleGestureEvent =
direction === 'vertical'
? this.handleGestureEventVertical
: this.handleGestureEventHorizontal;
return (
<StackGestureContext.Provider value={this.gestureRef}>
<View pointerEvents="box-none" {...rest}>
<Animated.Code exec={this.translate} />
{overlayStyle ? (
<Animated.View
pointerEvents="none"
style={[styles.overlay, overlayStyle]}
/>
) : null}
<Animated.View
style={[styles.container, containerStyle]}
pointerEvents="box-none"
>
<PanGestureHandler
ref={this.gestureRef}
enabled={layout.width !== 0 && gesturesEnabled}
onGestureEvent={handleGestureEvent}
onHandlerStateChange={handleGestureEvent}
{...this.gestureActivationCriteria()}
>
<Animated.View
style={[
styles.card,
cardStyle,
transparent ? styles.transparent : null,
]}
>
{children}
</Animated.View>
</PanGestureHandler>
</Animated.View>
</View>
</StackGestureContext.Provider>
);
}
}
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',
},
transparent: {
backgroundColor: 'transparent',
shadowOpacity: 0,
},
});

View File

@@ -0,0 +1,283 @@
import * as React from 'react';
import { View, StyleSheet, LayoutChangeEvent, Dimensions } from 'react-native';
import Animated from 'react-native-reanimated';
import { getDefaultHeaderHeight } from '../Header/HeaderSegment';
import HeaderContainer from '../Header/HeaderContainer';
import Card from './Card';
import {
Route,
Layout,
TransitionSpec,
CardStyleInterpolator,
HeaderStyleInterpolator,
HeaderMode,
GestureDirection,
SceneDescriptor,
NavigationProp,
HeaderScene,
} from '../../types';
type ProgressValues = {
[key: string]: Animated.Value<number>;
};
type Props = {
navigation: NavigationProp;
descriptors: { [key: string]: SceneDescriptor };
routes: Route[];
openingRoutes: string[];
closingRoutes: string[];
onGoBack: (props: { route: Route }) => void;
onOpenRoute: (props: { route: Route }) => void;
onCloseRoute: (props: { route: Route }) => void;
getGesturesEnabled: (props: { route: Route }) => boolean;
renderScene: (props: { route: Route }) => React.ReactNode;
transparentCard?: boolean;
headerMode: HeaderMode;
direction: GestureDirection;
onTransitionStart?: (
curr: { index: number },
prev: { index: number }
) => void;
onGestureBegin?: () => void;
onGestureCanceled?: () => void;
onGestureEnd?: () => void;
transitionSpec: {
open: TransitionSpec;
close: TransitionSpec;
};
cardStyleInterpolator: CardStyleInterpolator;
headerStyleInterpolator: HeaderStyleInterpolator;
};
type State = {
routes: Route[];
scenes: HeaderScene<Route>[];
progress: ProgressValues;
layout: Layout;
floaingHeaderHeight: number;
};
const dimensions = Dimensions.get('window');
const layout = { width: dimensions.width, height: dimensions.height };
export default class Stack extends React.Component<Props, State> {
static getDerivedStateFromProps(props: Props, state: State) {
if (props.routes === state.routes) {
return null;
}
const progress = props.routes.reduce(
(acc, curr) => {
acc[curr.key] =
state.progress[curr.key] ||
new Animated.Value(props.openingRoutes.includes(curr.key) ? 0 : 1);
return acc;
},
{} as ProgressValues
);
return {
routes: props.routes,
scenes: props.routes.map((route, index, self) => {
const previousRoute = self[index - 1];
const nextRoute = self[index + 1];
const current = progress[route.key];
const previous = previousRoute
? progress[previousRoute.key]
: undefined;
const next = nextRoute ? progress[nextRoute.key] : undefined;
const scene = {
route,
previous: previousRoute,
descriptor: props.descriptors[route.key],
progress: {
current,
next,
previous,
},
};
const oldScene = state.scenes[index];
if (
oldScene &&
scene.route === oldScene.route &&
scene.progress.current === oldScene.progress.current &&
scene.progress.next === oldScene.progress.next &&
scene.progress.previous === oldScene.progress.previous
) {
return oldScene;
}
return scene;
}),
progress,
};
}
state: State = {
routes: [],
scenes: [],
progress: {},
layout,
// 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.
floaingHeaderHeight: getDefaultHeaderHeight(layout),
};
private handleLayout = (e: LayoutChangeEvent) => {
const { height, width } = e.nativeEvent.layout;
if (
height === this.state.layout.height &&
width === this.state.layout.width
) {
return;
}
const layout = { width, height };
this.setState({ layout });
};
private handleFloatingHeaderLayout = (e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
if (height !== this.state.floaingHeaderHeight) {
this.setState({ floaingHeaderHeight: height });
}
};
render() {
const {
descriptors,
navigation,
routes,
openingRoutes,
closingRoutes,
onOpenRoute,
onCloseRoute,
onGoBack,
getGesturesEnabled,
renderScene,
transparentCard,
headerMode,
direction,
onTransitionStart,
onGestureBegin,
onGestureCanceled,
onGestureEnd,
transitionSpec,
cardStyleInterpolator,
headerStyleInterpolator,
} = this.props;
const { scenes, layout, progress, floaingHeaderHeight } = this.state;
const focusedRoute = navigation.state.routes[navigation.state.index];
return (
<React.Fragment>
<View
style={styles.container}
onLayout={this.handleLayout}
pointerEvents={layout.height && layout.width ? 'box-none' : 'none'}
>
{routes.map((route, index) => {
const focused = focusedRoute.key === route.key;
const current = progress[route.key];
const descriptor = descriptors[route.key];
const scene = scenes[index];
return (
<Card
key={route.key}
transparent={transparentCard}
direction={direction}
layout={layout}
current={current}
next={scene.progress.next}
closing={closingRoutes.includes(route.key)}
onOpen={() => onOpenRoute({ route })}
onClose={() => onCloseRoute({ route })}
animateIn={openingRoutes.includes(route.key)}
gesturesEnabled={getGesturesEnabled({ route })}
onTransitionStart={({ closing }) => {
onTransitionStart &&
onTransitionStart(
{ index: closing ? index - 1 : index },
{ index }
);
closing && onGoBack({ route });
}}
onGestureBegin={onGestureBegin}
onGestureCanceled={onGestureCanceled}
onGestureEnd={onGestureEnd}
gestureResponseDistance={
descriptor.options.gestureResponseDistance
}
transitionSpec={transitionSpec}
styleInterpolator={cardStyleInterpolator}
accessibilityElementsHidden={!focused}
importantForAccessibility={
focused ? 'auto' : 'no-hide-descendants'
}
pointerEvents="box-none"
style={[
StyleSheet.absoluteFill,
headerMode === 'float' &&
descriptor &&
descriptor.options.header !== null
? { marginTop: floaingHeaderHeight }
: null,
]}
>
{headerMode === 'screen' ? (
<HeaderContainer
mode="screen"
layout={layout}
scenes={[scenes[index - 1], scenes[index]]}
navigation={navigation}
styleInterpolator={headerStyleInterpolator}
/>
) : null}
{renderScene({ route })}
</Card>
);
})}
</View>
{headerMode === 'float' ? (
<HeaderContainer
mode="float"
layout={layout}
scenes={scenes}
navigation={navigation}
onLayout={this.handleFloatingHeaderLayout}
styleInterpolator={headerStyleInterpolator}
style={styles.header}
/>
) : null}
</React.Fragment>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'hidden',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
});

View File

@@ -0,0 +1,205 @@
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?: (
curr: { index: number },
prev: { index: number }
) => void;
onGestureBegin?: () => void;
onGestureCanceled?: () => void;
onGestureEnd?: () => void;
screenProps?: unknown;
};
type State = {
routes: Route[];
descriptors: { [key: string]: SceneDescriptor };
};
class StackView extends React.Component<Props, State> {
static getDerivedStateFromProps(
props: Readonly<Props>,
state: Readonly<State>
) {
const { navigation } = props;
const { transitions } = navigation.state;
let { routes } = navigation.state;
if (transitions.pushing.length) {
// If there are multiple routes being pushed/popped, we'll encounter glitches
// Only keep one screen animating at a time to avoid this
const toFilter = transitions.popping.length
? // If there are screens popping, we want to defer pushing of all screens
transitions.pushing
: transitions.pushing.length > 1
? // If there are more than 1 screens pushing, we want to defer pushing all except the first
transitions.pushing.slice(1)
: undefined;
if (toFilter) {
routes = routes.filter(route => !toFilter.includes(route.key));
}
}
if (transitions.popping.length) {
// Get indices of routes that were removed so we can preserve their position when transitioning away
const indices = state.routes.reduce(
(acc, curr, index) => {
if (transitions.popping.includes(curr.key)) {
acc.push([curr, index]);
}
return acc;
},
[] as Array<[Route, number]>
);
if (indices.length) {
routes = routes.slice();
indices.forEach(([route, index]) => {
routes.splice(index, 0, route);
});
}
}
return {
routes,
descriptors: { ...state.descriptors, ...props.descriptors },
};
}
state: State = {
routes: this.props.navigation.state.routes,
descriptors: {},
};
private getGesturesEnabled = ({ route }: { route: Route }) => {
const { routes } = this.props.navigation.state;
const isFirst = routes[0].key === route.key;
const isLast = routes[routes.length - 1].key === route.key;
if (isFirst || !isLast) {
return false;
}
const descriptor = this.state.descriptors[route.key];
return descriptor && descriptor.options.gesturesEnabled !== undefined
? descriptor.options.gesturesEnabled
: Platform.OS !== 'android';
};
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 (
<SceneView
screenProps={screenProps}
navigation={navigation}
component={SceneComponent}
/>
);
};
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({ toChildKey: route.key })
);
};
private handleOpenRoute = ({ route }: { route: Route }) => {
this.handleTransitionComplete({ route });
};
private handleCloseRoute = ({ route }: { route: Route }) => {
// @ts-ignore
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,
onTransitionStart,
onGestureBegin,
onGestureCanceled,
onGestureEnd,
} = this.props;
const { mode, ...config } = navigationConfig;
const { pushing, popping } = navigation.state.transitions;
const { routes, descriptors } = this.state;
const headerMode =
mode !== 'modal' && Platform.OS === 'ios' ? 'float' : 'screen';
const transitionPreset =
mode === 'modal' && Platform.OS === 'ios'
? ModalSlideFromBottomIOS
: DefaultTransition;
return (
<Stack
getGesturesEnabled={this.getGesturesEnabled}
routes={routes}
openingRoutes={pushing}
closingRoutes={popping}
onGoBack={this.handleGoBack}
onOpenRoute={this.handleOpenRoute}
onCloseRoute={this.handleCloseRoute}
onTransitionStart={onTransitionStart}
onGestureBegin={onGestureBegin}
onGestureCanceled={onGestureCanceled}
onGestureEnd={onGestureEnd}
renderScene={this.renderScene}
headerMode={headerMode}
navigation={navigation}
descriptors={descriptors}
{...transitionPreset}
{...config}
/>
);
}
}
export default StackView;

View File

@@ -0,0 +1,105 @@
import * as React from 'react';
import { ViewProps } from 'react-native';
import Animated from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';
import StackGestureContext from '../../utils/StackGestureContext';
import { Layout } from '../../types';
type Props = ViewProps & {
gesture: Animated.Value<number>;
velocity: Animated.Value<number>;
gestureState: Animated.Value<number>;
layout: Layout;
direction: 'horizontal' | 'vertical';
gesturesEnabled: boolean;
gestureResponseDistance?: {
vertical?: number;
horizontal?: number;
};
children: React.ReactNode;
};
/**
* 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;
export default class Swipeable extends React.Component<Props> {
private handleGestureEventHorizontal = Animated.event([
{
nativeEvent: {
translationX: this.props.gesture,
velocityX: this.props.velocity,
state: this.props.gestureState,
},
},
]);
private handleGestureEventVertical = Animated.event([
{
nativeEvent: {
translationY: this.props.gesture,
velocityY: this.props.velocity,
state: this.props.gestureState,
},
},
]);
private gestureActivationCriteria() {
const { layout, direction, gestureResponseDistance } = this.props;
// Doesn't make sense for a response distance of 0, so this works fine
const distance =
direction === 'vertical'
? (gestureResponseDistance && gestureResponseDistance.vertical) ||
GESTURE_RESPONSE_DISTANCE_VERTICAL
: (gestureResponseDistance && gestureResponseDistance.horizontal) ||
GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
if (direction === 'vertical') {
return {
maxDeltaX: 15,
minOffsetY: 5,
hitSlop: { bottom: -layout.height + distance },
};
} else {
return {
minOffsetX: 5,
maxDeltaY: 20,
hitSlop: { right: -layout.width + distance },
};
}
}
private gestureRef: React.Ref<PanGestureHandler> = React.createRef();
render() {
const {
layout,
direction,
gesturesEnabled,
children,
...rest
} = this.props;
const handleGestureEvent =
direction === 'vertical'
? this.handleGestureEventVertical
: this.handleGestureEventHorizontal;
return (
<StackGestureContext.Provider value={this.gestureRef}>
<PanGestureHandler
ref={this.gestureRef}
enabled={layout.width !== 0 && gesturesEnabled}
onGestureEvent={handleGestureEvent}
onHandlerStateChange={handleGestureEvent}
{...this.gestureActivationCriteria()}
>
<Animated.View {...rest}>{children}</Animated.View>
</PanGestureHandler>
</StackGestureContext.Provider>
);
}
}

View File

@@ -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<Props> {
render() {
return (
<Transitioner
render={this.renderStackviewLayout}
configureTransition={this.configureTransition}
screenProps={this.props.screenProps}
navigation={this.props.navigation}
descriptors={this.props.descriptors}
onTransitionStart={
this.props.onTransitionStart ||
this.props.navigationConfig.onTransitionStart
}
onTransitionEnd={this.handleTransitionEnd}
/>
);
}
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 (
<StackViewLayout
{...navigationConfig}
shadowEnabled={this.getShadowEnabled()}
cardOverlayEnabled={this.getCardOverlayEnabled()}
onGestureBegin={this.props.onGestureBegin}
onGestureCanceled={this.props.onGestureCanceled}
onGestureEnd={this.props.onGestureEnd}
screenProps={screenProps}
transitionProps={transitionProps}
lastTransitionProps={lastTransitionProps}
/>
);
};
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;

View File

@@ -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<ViewStyle>;
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 <StackView />.
*/
class Card extends React.Component<Props> {
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 (
<Screen
pointerEvents={pointerEvents}
onComponentRef={this.props.onComponentRef}
style={[containerAnimatedStyle, screenStyle]}
// @ts-ignore
active={active}
>
{!transparent && shadowOpacity ? (
<Animated.View
style={[styles.shadow, { shadowOpacity }]}
pointerEvents="none"
/>
) : null}
<Animated.View
{...getAccessibilityProps(isActive)}
style={[
transparent ? styles.transparent : styles.card,
backgroundColor && backgroundColor !== 'transparent'
? { backgroundColor }
: null,
]}
>
{children}
</Animated.View>
{overlayOpacity ? (
<Animated.View
pointerEvents="none"
style={[styles.overlay, { opacity: overlayOpacity }]}
/>
) : null}
</Screen>
);
}
}
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);

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

View File

@@ -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<T = {}>(
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,
};

View File

@@ -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<Props>
): React.ComponentType<Pick<Props, Exclude<keyof Props, keyof InjectedProps>>> {
class Container extends React.Component<Props> {
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 (
<Component
{...this.props}
pointerEvents={this.pointerEvents}
onComponentRef={this.handleComponentRef}
/>
);
}
}
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);
}
}

View File

@@ -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<any>;
onTransitionEnd?: (
current: TransitionProps,
previous?: TransitionProps
) => void | Promise<any>;
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<Props, State> {
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 (
<View onLayout={this.handleLayout} style={styles.main}>
{this.props.render(this.transitionProps, this.prevTransitionProps)}
</View>
);
}
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;

View File

@@ -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: '',
},
},
]);
});
});

View File

@@ -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: () => <div />,
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(
<Transitioner descriptors={{}} {...transitionerProps} />
);
component.update(
<Transitioner descriptors={{}} {...nextTransitionerProps} />
);
expect(onTransitionStartCallback).not.toBeCalled();
expect(onTransitionEndCallback).not.toBeCalled();
});
});

View File

@@ -2,7 +2,15 @@ declare module '@react-navigation/core' {
import * as React from 'react';
export const StackActions: {
completeTransition<T extends { key?: string } | undefined>(
completeTransition<
T extends { key?: string; toChildKey?: string } | undefined
>(
options?: T
): { type: string } & T;
push<T extends { key?: string; immediate?: boolean } | undefined>(
options?: T
): { type: string } & T;
pop<T extends { key?: string; immediate?: boolean } | undefined>(
options?: T
): { type: string } & T;
};

View File

@@ -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"
@@ -7046,6 +7051,13 @@ react-native-safe-area-view@^0.13.0:
dependencies:
hoist-non-react-statics "^2.3.1"
react-native-safe-area-view@^0.14.4:
version "0.14.4"
resolved "https://registry.yarnpkg.com/react-native-safe-area-view/-/react-native-safe-area-view-0.14.4.tgz#1647fa74fd452cb8cee4f47bd08de3829451bf5d"
integrity sha512-ypDQVoAyNHBhMR1IGfadm8kskNzPg5czrDAzQEu5MXG9Ahoi5f1cL/rT2KO+R9f6xRjf6b1IjY53m0B0xHRd0A==
dependencies:
hoist-non-react-statics "^2.3.1"
"react-native-screens@^1.0.0 || ^1.0.0-alpha", react-native-screens@^1.0.0-alpha.22:
version "1.0.0-alpha.22"
resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-1.0.0-alpha.22.tgz#7a120377b52aa9bbb94d0b8541a014026be9289b"