mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-03-26 09:14:22 +08:00
refactor: rewrite based on reanimated
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
[
|
||||
'react-navigation-stack',
|
||||
'react-native-gesture-handler',
|
||||
'react-native-reanimated',
|
||||
'react-native-vector-icons',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')} />
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
140
packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx
Normal file
140
packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
69
packages/stack/src/TransitionConfigs/TransitionPresets.tsx
Normal file
69
packages/stack/src/TransitionConfigs/TransitionPresets.tsx
Normal 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,
|
||||
});
|
||||
43
packages/stack/src/TransitionConfigs/TransitionSpecs.tsx
Normal file
43
packages/stack/src/TransitionConfigs/TransitionSpecs.tsx
Normal 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),
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
33
packages/stack/src/utils/memoize.tsx
Normal file
33
packages/stack/src/utils/memoize.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
packages/stack/src/views/Header/HeaderBackground.tsx
Normal file
29
packages/stack/src/views/Header/HeaderBackground.tsx
Normal 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)`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
86
packages/stack/src/views/Header/HeaderContainer.tsx
Normal file
86
packages/stack/src/views/Header/HeaderContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
packages/stack/src/views/Header/HeaderSegment.tsx
Normal file
256
packages/stack/src/views/Header/HeaderSegment.tsx
Normal 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' },
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
501
packages/stack/src/views/Stack/Card.tsx
Executable file
501
packages/stack/src/views/Stack/Card.tsx
Executable 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,
|
||||
},
|
||||
});
|
||||
283
packages/stack/src/views/Stack/Stack.tsx
Executable file
283
packages/stack/src/views/Stack/Stack.tsx
Executable 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,
|
||||
},
|
||||
});
|
||||
205
packages/stack/src/views/Stack/StackView.tsx
Normal file
205
packages/stack/src/views/Stack/StackView.tsx
Normal 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;
|
||||
105
packages/stack/src/views/Stack/Swipeable.tsx
Normal file
105
packages/stack/src/views/Stack/Swipeable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
10
packages/stack/types/@react-navigation/core.d.ts
vendored
10
packages/stack/types/@react-navigation/core.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user