Header transition presets with support for standard iOS transition style (#3526)

Header transition presets with approximate support for UIKit transition style
This commit is contained in:
Brent Vatne 2018-02-16 12:41:59 -08:00 committed by GitHub
parent 5febb81a1c
commit 3c3668c952
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 573 additions and 84 deletions

View File

@ -1,7 +1,7 @@
/* @flow */ /* @flow */
import React from 'react'; import React from 'react';
import { Constants, ScreenOrientation } from 'expo'; import { Asset, Constants, ScreenOrientation } from 'expo';
ScreenOrientation.allow(ScreenOrientation.Orientation.ALL); ScreenOrientation.allow(ScreenOrientation.Orientation.ALL);
@ -28,13 +28,14 @@ import StacksInTabs from './StacksInTabs';
import StacksOverTabs from './StacksOverTabs'; import StacksOverTabs from './StacksOverTabs';
import StacksWithKeys from './StacksWithKeys'; import StacksWithKeys from './StacksWithKeys';
import SimpleStack from './SimpleStack'; import SimpleStack from './SimpleStack';
import StackWithHeaderPreset from './StackWithHeaderPreset';
import SimpleTabs from './SimpleTabs'; import SimpleTabs from './SimpleTabs';
import TabAnimations from './TabAnimations'; import TabAnimations from './TabAnimations';
const ExampleInfo = { const ExampleInfo = {
SimpleStack: { SimpleStack: {
name: 'Stack Example', name: 'Stack Example',
description: 'A card stack!', description: 'A card stack',
}, },
SimpleTabs: { SimpleTabs: {
name: 'Tabs Example', name: 'Tabs Example',
@ -44,6 +45,10 @@ const ExampleInfo = {
name: 'Drawer Example', name: 'Drawer Example',
description: 'Android-style drawer navigation', description: 'Android-style drawer navigation',
}, },
StackWithHeaderPreset: {
name: 'UIKit-style Header Transitions',
description: 'Masked back button and sliding header items. iOS only.',
},
// MultipleDrawer: { // MultipleDrawer: {
// name: 'Multiple Drawer Example', // name: 'Multiple Drawer Example',
// description: 'Add any drawer you need', // description: 'Add any drawer you need',
@ -102,6 +107,7 @@ const ExampleRoutes = {
// MultipleDrawer: { // MultipleDrawer: {
// screen: MultipleDrawer, // screen: MultipleDrawer,
// }, // },
StackWithHeaderPreset: StackWithHeaderPreset,
TabsInDrawer: TabsInDrawer, TabsInDrawer: TabsInDrawer,
CustomTabs: CustomTabs, CustomTabs: CustomTabs,
CustomTransitioner: CustomTransitioner, CustomTransitioner: CustomTransitioner,
@ -128,6 +134,11 @@ class MainScreen extends React.Component<any, State> {
scrollY: new Animated.Value(0), scrollY: new Animated.Value(0),
}; };
componentWillMount() {
Asset.fromModule(require('react-navigation/src/views/assets/back-icon-mask.png')).downloadAsync();
Asset.fromModule(require('react-navigation/src/views/assets/back-icon.png')).downloadAsync();
}
render() { render() {
const { navigation } = this.props; const { navigation } = this.props;

View File

@ -19,14 +19,12 @@ type MyNavScreenProps = {
class MyBackButton extends React.Component<any, any> { class MyBackButton extends React.Component<any, any> {
render() { render() {
return ( return <Button onPress={this._navigateBack} title="Custom Back" />;
<Button onPress={this._navigateBack} title="Custom Back" />
);
} }
_navigateBack = () => { _navigateBack = () => {
this.props.navigation.goBack(null); this.props.navigation.goBack(null);
} };
} }
const MyBackButtonWithNavigation = withNavigation(MyBackButton); const MyBackButtonWithNavigation = withNavigation(MyBackButton);
@ -108,7 +106,7 @@ type MyPhotosScreenProps = {
class MyPhotosScreen extends React.Component<MyPhotosScreenProps> { class MyPhotosScreen extends React.Component<MyPhotosScreenProps> {
static navigationOptions = { static navigationOptions = {
title: 'Photos', title: 'Photos',
headerLeft: <MyBackButtonWithNavigation /> headerLeft: <MyBackButtonWithNavigation />,
}; };
_s0: NavigationEventSubscription; _s0: NavigationEventSubscription;
_s1: NavigationEventSubscription; _s1: NavigationEventSubscription;
@ -180,7 +178,8 @@ MyProfileScreen.navigationOptions = props => {
}; };
}; };
const SimpleStack = StackNavigator({ const SimpleStack = StackNavigator(
{
Home: { Home: {
screen: MyHomeScreen, screen: MyHomeScreen,
}, },
@ -192,6 +191,7 @@ const SimpleStack = StackNavigator({
path: 'photos/:name', path: 'photos/:name',
screen: MyPhotosScreen, screen: MyPhotosScreen,
}, },
}); }
);
export default SimpleStack; export default SimpleStack;

View File

@ -0,0 +1,68 @@
/**
* @flow
*/
import type { NavigationScreenProp } from 'react-navigation';
import * as React from 'react';
import { Button, ScrollView, StatusBar } from 'react-native';
import { StackNavigator, SafeAreaView } from 'react-navigation';
type NavScreenProps = {
navigation: NavigationScreenProp<*>,
};
class HomeScreen extends React.Component<NavScreenProps> {
static navigationOptions = {
title: 'Welcome',
};
render() {
const { navigation } = this.props;
return (
<SafeAreaView>
<Button
onPress={() => navigation.push('Other')}
title="Push another screen"
/>
<Button onPress={() => navigation.pop()} title="Pop" />
<Button onPress={() => navigation.goBack(null)} title="Go back" />
<StatusBar barStyle="default" />
</SafeAreaView>
);
}
}
class OtherScreen extends React.Component<NavScreenProps> {
static navigationOptions = {
title: 'Your title here',
};
render() {
const { navigation } = this.props;
return (
<SafeAreaView>
<Button
onPress={() => navigation.push('Other')}
title="Push another screen"
/>
<Button onPress={() => navigation.pop()} title="Pop" />
<Button onPress={() => navigation.goBack(null)} title="Go back" />
<StatusBar barStyle="default" />
</SafeAreaView>
);
}
}
const StackWithHeaderPreset = StackNavigator(
{
Home: HomeScreen,
Other: OtherScreen,
},
{
headerTransitionPreset: 'uikit',
}
);
export default StackWithHeaderPreset;

View File

@ -408,6 +408,7 @@ declare module 'react-navigation' {
declare export type NavigationStackViewConfig = {| declare export type NavigationStackViewConfig = {|
mode?: 'card' | 'modal', mode?: 'card' | 'modal',
headerMode?: HeaderMode, headerMode?: HeaderMode,
headerTransitionPreset?: 'fade-in-place' | 'uikit',
cardStyle?: ViewStyleProp, cardStyle?: ViewStyleProp,
transitionConfig?: () => TransitionConfig, transitionConfig?: () => TransitionConfig,
onTransitionStart?: () => void, onTransitionStart?: () => void,

View File

@ -1,6 +1,6 @@
{ {
"name": "react-navigation", "name": "react-navigation",
"version": "1.0.3", "version": "1.1.0",
"description": "Routing and navigation for your React Native apps", "description": "Routing and navigation for your React Native apps",
"main": "src/react-navigation.js", "main": "src/react-navigation.js",
"repository": { "repository": {

View File

@ -15,6 +15,7 @@ export default (routeConfigMap, stackConfig = {}) => {
initialRouteParams, initialRouteParams,
paths, paths,
headerMode, headerMode,
headerTransitionPreset,
mode, mode,
cardStyle, cardStyle,
transitionConfig, transitionConfig,
@ -38,6 +39,7 @@ export default (routeConfigMap, stackConfig = {}) => {
<CardStackTransitioner <CardStackTransitioner
{...props} {...props}
headerMode={headerMode} headerMode={headerMode}
headerTransitionPreset={headerTransitionPreset}
mode={mode} mode={mode}
cardStyle={cardStyle} cardStyle={cardStyle}
transitionConfig={transitionConfig} transitionConfig={transitionConfig}

View File

@ -167,7 +167,14 @@ exports[`DrawerNavigator renders successfully 1`] = `
accessible={true} accessible={true}
collapsable={undefined} collapsable={undefined}
hasTVPreferredFocus={undefined} hasTVPreferredFocus={undefined}
hitSlop={undefined} hitSlop={
Object {
"bottom": 15,
"left": 15,
"right": 15,
"top": 15,
}
}
isTVSelectable={true} isTVSelectable={true}
nativeID={undefined} nativeID={undefined}
onLayout={undefined} onLayout={undefined}

View File

@ -79,6 +79,7 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
collapsable={undefined} collapsable={undefined}
getScreenDetails={[Function]} getScreenDetails={[Function]}
headerMode={undefined} headerMode={undefined}
headerTransitionPreset={undefined}
index={0} index={0}
layout={ layout={
Object { Object {
@ -89,7 +90,9 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
"width": 0, "width": 0,
} }
} }
leftButtonInterpolator={[Function]}
leftInterpolator={[Function]} leftInterpolator={[Function]}
leftLabelInterpolator={[Function]}
mode="float" mode="float"
navigation={ navigation={
Object { Object {
@ -127,8 +130,10 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
"getStateForAction": [Function], "getStateForAction": [Function],
} }
} }
titleFromLeftInterpolator={[Function]}
titleInterpolator={[Function]} titleInterpolator={[Function]}
transitionConfig={undefined} transitionConfig={undefined}
transitionPreset="fade-in-place"
> >
<View <View
collapsable={undefined} collapsable={undefined}
@ -184,11 +189,6 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
"position": "absolute", "position": "absolute",
"right": 70, "right": 70,
"top": 0, "top": 0,
"transform": Array [
Object {
"translateX": 0,
},
],
} }
} }
> >
@ -204,7 +204,7 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
Object { Object {
"color": "rgba(0, 0, 0, .9)", "color": "rgba(0, 0, 0, .9)",
"fontSize": 17, "fontSize": 17,
"fontWeight": "600", "fontWeight": "700",
"marginHorizontal": 16, "marginHorizontal": 16,
"textAlign": "center", "textAlign": "center",
} }
@ -318,6 +318,7 @@ exports[`StackNavigator renders successfully 1`] = `
collapsable={undefined} collapsable={undefined}
getScreenDetails={[Function]} getScreenDetails={[Function]}
headerMode={undefined} headerMode={undefined}
headerTransitionPreset={undefined}
index={0} index={0}
layout={ layout={
Object { Object {
@ -328,7 +329,9 @@ exports[`StackNavigator renders successfully 1`] = `
"width": 0, "width": 0,
} }
} }
leftButtonInterpolator={[Function]}
leftInterpolator={[Function]} leftInterpolator={[Function]}
leftLabelInterpolator={[Function]}
mode="float" mode="float"
navigation={ navigation={
Object { Object {
@ -366,8 +369,10 @@ exports[`StackNavigator renders successfully 1`] = `
"getStateForAction": [Function], "getStateForAction": [Function],
} }
} }
titleFromLeftInterpolator={[Function]}
titleInterpolator={[Function]} titleInterpolator={[Function]}
transitionConfig={undefined} transitionConfig={undefined}
transitionPreset="fade-in-place"
> >
<View <View
collapsable={undefined} collapsable={undefined}
@ -423,11 +428,6 @@ exports[`StackNavigator renders successfully 1`] = `
"position": "absolute", "position": "absolute",
"right": 0, "right": 0,
"top": 0, "top": 0,
"transform": Array [
Object {
"translateX": 0,
},
],
} }
} }
> >
@ -443,7 +443,7 @@ exports[`StackNavigator renders successfully 1`] = `
Object { Object {
"color": "rgba(0, 0, 0, .9)", "color": "rgba(0, 0, 0, .9)",
"fontSize": 17, "fontSize": 17,
"fontWeight": "600", "fontWeight": "700",
"marginHorizontal": 16, "marginHorizontal": 16,
"textAlign": "center", "textAlign": "center",
} }

View File

@ -137,6 +137,7 @@ class CardStack extends React.Component {
...passProps, ...passProps,
scene, scene,
mode: headerMode, mode: headerMode,
transitionPreset: this._getHeaderTransitionPreset(),
getScreenDetails: this._getScreenDetails, getScreenDetails: this._getScreenDetails,
leftInterpolator: headerLeftInterpolator, leftInterpolator: headerLeftInterpolator,
titleInterpolator: headerTitleInterpolator, titleInterpolator: headerTitleInterpolator,
@ -363,6 +364,21 @@ class CardStack extends React.Component {
return 'float'; return 'float';
} }
_getHeaderTransitionPreset() {
// On Android or with header mode screen, we always just use in-place,
// we ignore the option entirely (at least until we have other presets)
if (Platform.OS === 'android' || this._getHeaderMode() === 'screen') {
return 'fade-in-place';
}
// TODO: validations: 'fade-in-place' or 'uikit' are valid
if (this.props.headerTransitionPreset) {
return this.props.headerTransitionPreset;
} else {
return 'fade-in-place';
}
}
_renderInnerScene(SceneComponent, scene) { _renderInnerScene(SceneComponent, scene) {
const { navigation } = this._getScreenDetails(scene); const { navigation } = this._getScreenDetails(scene);
const { screenProps } = this.props; const { screenProps } = this.props;

View File

@ -57,6 +57,7 @@ class CardStackTransitioner extends React.Component {
const { const {
screenProps, screenProps,
headerMode, headerMode,
headerTransitionPreset,
mode, mode,
router, router,
cardStyle, cardStyle,
@ -66,6 +67,7 @@ class CardStackTransitioner extends React.Component {
<CardStack <CardStack
screenProps={screenProps} screenProps={screenProps}
headerMode={headerMode} headerMode={headerMode}
headerTransitionPreset={headerTransitionPreset}
mode={mode} mode={mode}
router={router} router={router}
cardStyle={cardStyle} cardStyle={cardStyle}

View File

@ -3,8 +3,10 @@ import React from 'react';
import { import {
Animated, Animated,
Dimensions, Dimensions,
Image,
Platform, Platform,
StyleSheet, StyleSheet,
MaskedViewIOS,
View, View,
ViewPropTypes, ViewPropTypes,
} from 'react-native'; } from 'react-native';
@ -12,6 +14,7 @@ import SafeAreaView from 'react-native-safe-area-view';
import HeaderTitle from './HeaderTitle'; import HeaderTitle from './HeaderTitle';
import HeaderBackButton from './HeaderBackButton'; import HeaderBackButton from './HeaderBackButton';
import ModularHeaderBackButton from './ModularHeaderBackButton';
import HeaderStyleInterpolator from './HeaderStyleInterpolator'; import HeaderStyleInterpolator from './HeaderStyleInterpolator';
import withOrientation from '../withOrientation'; import withOrientation from '../withOrientation';
@ -19,9 +22,18 @@ const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0; const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0;
const TITLE_OFFSET = Platform.OS === 'ios' ? 70 : 56; const TITLE_OFFSET = Platform.OS === 'ios' ? 70 : 56;
const getAppBarHeight = isLandscape => {
return Platform.OS === 'ios'
? isLandscape && !Platform.isPad ? 32 : 44
: 56;
};
class Header extends React.PureComponent { class Header extends React.PureComponent {
static defaultProps = { static defaultProps = {
leftInterpolator: HeaderStyleInterpolator.forLeft, leftInterpolator: HeaderStyleInterpolator.forLeft,
leftButtonInterpolator: HeaderStyleInterpolator.forLeftButton,
leftLabelInterpolator: HeaderStyleInterpolator.forLeftLabel,
titleFromLeftInterpolator: HeaderStyleInterpolator.forCenterFromLeft,
titleInterpolator: HeaderStyleInterpolator.forCenter, titleInterpolator: HeaderStyleInterpolator.forCenter,
rightInterpolator: HeaderStyleInterpolator.forRight, rightInterpolator: HeaderStyleInterpolator.forRight,
}; };
@ -116,15 +128,14 @@ class Header extends React.PureComponent {
_renderLeftComponent = props => { _renderLeftComponent = props => {
const { options } = this.props.getScreenDetails(props.scene); const { options } = this.props.getScreenDetails(props.scene);
if ( if (
React.isValidElement(options.headerLeft) || React.isValidElement(options.headerLeft) ||
options.headerLeft === null options.headerLeft === null
) { ) {
return options.headerLeft; return options.headerLeft;
} }
if (props.scene.index === 0) {
return null;
}
const backButtonTitle = this._getBackButtonTitleString(props.scene); const backButtonTitle = this._getBackButtonTitleString(props.scene);
const truncatedBackButtonTitle = this._getTruncatedBackButtonTitle( const truncatedBackButtonTitle = this._getTruncatedBackButtonTitle(
props.scene props.scene
@ -147,6 +158,36 @@ class Header extends React.PureComponent {
); );
}; };
_renderModularLeftComponent = (
props,
ButtonContainerComponent,
LabelContainerComponent
) => {
const { options } = this.props.getScreenDetails(props.scene);
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;
return (
<ModularHeaderBackButton
onPress={this._navigateBack}
ButtonContainerComponent={ButtonContainerComponent}
LabelContainerComponent={LabelContainerComponent}
pressColorAndroid={options.headerPressColorAndroid}
tintColor={options.headerTintColor}
buttonImage={options.headerBackImage}
title={backButtonTitle}
truncatedTitle={truncatedBackButtonTitle}
titleStyle={options.headerBackTitleStyle}
width={width}
/>
);
};
_renderRightComponent = props => { _renderRightComponent = props => {
const details = this.props.getScreenDetails(props.scene); const details = this.props.getScreenDetails(props.scene);
const { headerRight } = details.options; const { headerRight } = details.options;
@ -154,16 +195,42 @@ class Header extends React.PureComponent {
}; };
_renderLeft(props) { _renderLeft(props) {
const { options } = this.props.getScreenDetails(props.scene);
if (props.scene.index === 0) {
return null;
}
const { transitionPreset } = this.props;
// On Android, or if we have a custom header left, or if we have a custom back image, we
// do not use the modular header (which is the one that imitates UINavigationController)
if (
transitionPreset !== 'uikit' ||
options.headerBackImage ||
options.headerLeft ||
options.headerLeft === null
) {
return this._renderSubView( return this._renderSubView(
props, props,
'left', 'left',
this._renderLeftComponent, this._renderLeftComponent,
this.props.leftInterpolator this.props.leftInterpolator
); );
} else {
return this._renderModularSubView(
props,
'left',
this._renderModularLeftComponent,
this.props.leftLabelInterpolator,
this.props.leftButtonInterpolator
);
}
} }
_renderTitle(props, options) { _renderTitle(props, options) {
const style = {}; const style = {};
const { transitionPreset } = this.props;
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
if (!options.hasLeftComponent) { if (!options.hasLeftComponent) {
@ -185,7 +252,9 @@ class Header extends React.PureComponent {
{ ...props, style }, { ...props, style },
'title', 'title',
this._renderTitleComponent, this._renderTitleComponent,
this.props.titleInterpolator transitionPreset === 'uikit'
? this.props.titleFromLeftInterpolator
: this.props.titleInterpolator
); );
} }
@ -198,6 +267,59 @@ class Header extends React.PureComponent {
); );
} }
_renderModularSubView(
props,
name,
renderer,
labelStyleInterpolator,
buttonStyleInterpolator
) {
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 ButtonContainer = ({ children }) => (
<Animated.View
style={[buttonStyleInterpolator({ ...this.props, ...props })]}
>
{children}
</Animated.View>
);
const LabelContainer = ({ children }) => (
<Animated.View
style={[labelStyleInterpolator({ ...this.props, ...props })]}
>
{children}
</Animated.View>
);
const subView = renderer(props, ButtonContainer, LabelContainer);
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>
);
}
_renderSubView(props, name, renderer, styleInterpolator) { _renderSubView(props, name, renderer, styleInterpolator) {
const { scene } = props; const { scene } = props;
const { index, isStale, key } = scene; const { index, isStale, key } = scene;
@ -246,16 +368,47 @@ class Header extends React.PureComponent {
hasRightComponent: !!right, hasRightComponent: !!right,
}); });
const wrapperProps = {
style: [StyleSheet.absoluteFill, styles.header],
key: `scene_${props.scene.key}`,
};
const { isLandscape, transitionPreset } = this.props;
const { options } = this.props.getScreenDetails(props.scene);
if (
options.headerLeft ||
options.headerBackImage ||
Platform.OS === 'android' ||
transitionPreset !== 'uikit'
) {
return ( return (
<View <View {...wrapperProps}>
style={[StyleSheet.absoluteFill, styles.header]}
key={`scene_${props.scene.key}`}
>
{title} {title}
{left} {left}
{right} {right}
</View> </View>
); );
} else {
return (
<MaskedViewIOS
{...wrapperProps}
maskElement={
<View style={styles.iconMaskContainer}>
<Image
source={require('../assets/back-icon-mask.png')}
style={styles.iconMask}
/>
<View style={styles.iconMaskFillerRect} />
</View>
}
>
{title}
{left}
{right}
</MaskedViewIOS>
);
}
} }
render() { render() {
@ -293,8 +446,7 @@ class Header extends React.PureComponent {
const { options } = this.props.getScreenDetails(scene); const { options } = this.props.getScreenDetails(scene);
const { headerStyle } = options; const { headerStyle } = options;
const appBarHeight = const appBarHeight = getAppBarHeight(isLandscape);
Platform.OS === 'ios' ? (isLandscape && !Platform.isPad ? 32 : 44) : 56;
const containerStyles = [ const containerStyles = [
styles.container, styles.container,
{ {
@ -350,6 +502,25 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
iconMaskContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
},
iconMaskFillerRect: {
flex: 1,
backgroundColor: '#d8d8d8',
marginLeft: -3,
},
iconMask: {
// These are mostly the same as the icon in ModularHeaderBackButton
height: 21,
width: 12,
marginLeft: 9,
marginTop: -0.5, // resizes down to 20.5
alignSelf: 'center',
resizeMode: 'contain',
},
title: { title: {
bottom: 0, bottom: 0,
left: TITLE_OFFSET, left: TITLE_OFFSET,

View File

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

View File

@ -15,7 +15,7 @@ const HeaderTitle = ({ style, ...rest }) => (
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
fontSize: Platform.OS === 'ios' ? 17 : 20, fontSize: Platform.OS === 'ios' ? 17 : 20,
fontWeight: Platform.OS === 'ios' ? '600' : '500', fontWeight: Platform.OS === 'ios' ? '700' : '500',
color: 'rgba(0, 0, 0, .9)', color: 'rgba(0, 0, 0, .9)',
textAlign: Platform.OS === 'ios' ? 'center' : 'left', textAlign: Platform.OS === 'ios' ? 'center' : 'left',
marginHorizontal: 16, marginHorizontal: 16,

View File

@ -0,0 +1,118 @@
import React from 'react';
import { I18nManager, Image, Text, View, StyleSheet } from 'react-native';
import TouchableItem from '../TouchableItem';
class ModularHeaderBackButton extends React.PureComponent {
static defaultProps = {
tintColor: '#037aff',
truncatedTitle: 'Back',
// eslint-disable-next-line global-require
buttonImage: require('../assets/back-icon.png'),
};
state = {};
_onTextLayout = e => {
if (this.state.initialTextWidth) {
return;
}
this.setState({
initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
});
};
render() {
const {
buttonImage,
onPress,
width,
title,
titleStyle,
tintColor,
truncatedTitle,
} = this.props;
const renderTruncated =
this.state.initialTextWidth && width
? this.state.initialTextWidth > width
: false;
let backButtonTitle = renderTruncated ? truncatedTitle : title;
// TODO: When we've sorted out measuring in the header, let's revisit this.
// This is clearly a bad workaround.
if (backButtonTitle.length > 8) {
backButtonTitle = truncatedTitle;
}
const { ButtonContainerComponent, LabelContainerComponent } = this.props;
return (
<TouchableItem
accessibilityComponentType="button"
accessibilityLabel={backButtonTitle}
accessibilityTraits="button"
testID="header-back"
delayPressIn={0}
onPress={onPress}
style={styles.container}
borderless
>
<View style={styles.container}>
<ButtonContainerComponent>
<Image
style={[
styles.icon,
!!title && styles.iconWithTitle,
!!tintColor && { tintColor },
]}
source={buttonImage}
/>
</ButtonContainerComponent>
{typeof backButtonTitle === 'string' && (
<LabelContainerComponent>
<Text
onLayout={this._onTextLayout}
style={[
styles.title,
!!tintColor && { color: tintColor },
titleStyle,
]}
numberOfLines={1}
>
{backButtonTitle}
</Text>
</LabelContainerComponent>
)}
</View>
</TouchableItem>
);
}
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
flexDirection: 'row',
backgroundColor: 'transparent',
},
title: {
fontSize: 17,
paddingRight: 10,
},
icon: {
height: 21,
width: 12,
marginLeft: 9,
marginRight: 22,
marginVertical: 12,
resizeMode: 'contain',
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }],
},
iconWithTitle: {
marginRight: 3,
},
});
export default ModularHeaderBackButton;

View File

@ -21,6 +21,7 @@ export default class TouchableItem extends React.Component {
static defaultProps = { static defaultProps = {
borderless: false, borderless: false,
pressColor: 'rgba(0, 0, 0, .32)', pressColor: 'rgba(0, 0, 0, .32)',
hitSlop: { top: 15, left: 15, right: 15, bottom: 15 },
}; };
render() { render() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 491 B