Add back button label to header (#257)

This commit is contained in:
Mike Grabowski 2017-02-20 00:39:56 +01:00 committed by Satyajit Sahoo
parent 4b2f94544a
commit 79f21277cb
5 changed files with 224 additions and 149 deletions

View File

@ -120,6 +120,7 @@ All `navigationOptions` for the `StackNavigator`:
- `header` - a config object for the header bar:
- `visible` - Boolean toggle of header visibility. Only works when `headerMode` is `screen`.
- `title` - Title string used by the navigation bar, or a custom React component
- `backTitle` - Title string used by the back button or `null` to disable label. Defaults to `title` value by default
- `right` - Custom React Element to display on the right side of the header
- `left` - Custom React Element to display on the left side of the header
- `style` - Style object for the navigation bar

View File

@ -436,3 +436,14 @@ export type NavigationSceneRenderer = (
export type NavigationStyleInterpolator = (
props: NavigationSceneRendererProps,
) => Style;
export type LayoutEvent = {
nativeEvent: {
layout: {
x: number;
y: number;
width: number;
height: number;
},
};
};

View File

@ -20,11 +20,11 @@ import addNavigationHelpers from '../addNavigationHelpers';
import type {
NavigationScene,
NavigationRouter,
NavigationState,
NavigationAction,
NavigationScreenProp,
NavigationSceneRendererProps,
NavigationStyleInterpolator,
LayoutEvent,
Style,
} from '../TypeDefinition';
@ -34,9 +34,9 @@ type SubViewProps = NavigationSceneRendererProps & {
onNavigateBack?: () => void,
};
type Navigation = NavigationScreenProp<NavigationState, NavigationAction>;
type Navigation = NavigationScreenProp<*, NavigationAction>;
type SubViewRenderer = (subViewProps: SubViewProps) => ?React.Element<*>;
type SubViewRenderer = (subViewProps: SubViewProps) => ?React.Element<any>;
export type HeaderProps = NavigationSceneRendererProps & {
mode: HeaderMode,
@ -50,10 +50,16 @@ export type HeaderProps = NavigationSceneRendererProps & {
type SubViewName = 'left' | 'title' | 'right';
type HeaderState = {
widths: {
[key: number]: number,
},
};
const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0;
class Header extends React.Component<void, HeaderProps, void> {
class Header extends React.PureComponent<void, HeaderProps, HeaderState> {
static HEIGHT = APPBAR_HEIGHT + STATUSBAR_HEIGHT;
static Title = HeaderTitle;
@ -72,13 +78,9 @@ class Header extends React.Component<void, HeaderProps, void> {
props: HeaderProps;
shouldComponentUpdate(nextProps: HeaderProps, nextState: any): boolean {
return ReactComponentWithPureRenderMixin.shouldComponentUpdate.call(
this,
nextProps,
nextState
);
}
state = {
widths: {},
};
_getHeaderTitle(navigation: Navigation): ?string {
const header = this.props.router.getScreenConfig(navigation, 'header');
@ -91,6 +93,14 @@ class Header extends React.Component<void, HeaderProps, void> {
return typeof title === 'string' ? title : undefined;
}
_getBackButtonTitle(navigation: Navigation): ?string {
const header = this.props.router.getScreenConfig(navigation, 'header') || {};
if (header.backTitle === null) {
return undefined;
}
return header.backTitle || this._getHeaderTitle(navigation);
}
_getHeaderTintColor(navigation: Navigation): ?string {
const header = this.props.router.getScreenConfig(navigation, 'header');
if (header && header.tintColor) {
@ -107,61 +117,53 @@ class Header extends React.Component<void, HeaderProps, void> {
return undefined;
}
_renderTitleComponent = (props: SubViewProps): React.Element<HeaderTitle> => {
_renderTitleComponent = (props: SubViewProps) => {
const titleStyle = this._getHeaderTitleStyle(props.navigation);
const color = this._getHeaderTintColor(props.navigation);
const title = this._getHeaderTitle(props.navigation);
return <HeaderTitle style={[color ? { color } : null, titleStyle]}>{title}</HeaderTitle>;
return (
<HeaderTitle
style={[color ? { color } : null, titleStyle]}
>
{title}
</HeaderTitle>
);
};
_renderLeftComponent = (props: SubViewProps): ?React.Element<HeaderBackButton> => {
_renderLeftComponent = (props: SubViewProps) => {
if (props.scene.index === 0 || !props.onNavigateBack) {
return null;
}
const tintColor = this._getHeaderTintColor(props.navigation);
// @todo(grabobu):
// We have implemented support for back button label (which works 100% fine),
// but when title is too long, it will overlap the <HeaderTitle />.
// We had to revert the PR implementing that because of Android issues,
// I will land it this week and re-enable that for next release.
//
// const previousNavigation = addNavigationHelpers({
// ...props.navigation,
// state: props.scenes[props.scene.index - 1].route,
// });
// const backButtonTitle = this._getHeaderTitle(previousNavigation);
const previousNavigation = addNavigationHelpers({
...props.navigation,
state: props.scenes[props.scene.index - 1].route,
});
const backButtonTitle = this._getBackButtonTitle(previousNavigation);
return (
<HeaderBackButton
onPress={props.onNavigateBack}
tintColor={tintColor}
title={backButtonTitle}
/>
);
};
_renderRightComponent = () => null;
_renderLeft = (props: NavigationSceneRendererProps): ?React.Element<*> => this._renderSubView(
_renderLeft(props: NavigationSceneRendererProps): ?React.Element<*> {
return this._renderSubView(
props,
'left',
this.props.renderLeftComponent,
this._renderLeftComponent,
HeaderStyleInterpolator.forLeft,
);
}
_renderTitle = (props: NavigationSceneRendererProps, options: *): ?React.Element<*> => {
const style = {};
if (Platform.OS === 'android') {
if (!options.hasLeftComponent) {
style.left = 0;
}
if (!options.hasRightComponent) {
style.right = 0;
}
}
_renderTitle(props: NavigationSceneRendererProps): ?React.Element<*> {
return this._renderSubView(
{ ...props, style },
props,
'title',
this.props.renderTitleComponent,
this._renderTitleComponent,
@ -169,13 +171,15 @@ class Header extends React.Component<void, HeaderProps, void> {
);
}
_renderRight = (props: NavigationSceneRendererProps): ?React.Element<*> => this._renderSubView(
_renderRight(props: NavigationSceneRendererProps): ?React.Element<*> {
return this._renderSubView(
props,
'right',
this.props.renderRightComponent,
this._renderRightComponent,
HeaderStyleInterpolator.forRight,
);
}
_renderSubView(
props: NavigationSceneRendererProps,
@ -212,16 +216,34 @@ class Header extends React.Component<void, HeaderProps, void> {
subView = defaultRenderer(subViewProps);
}
if (subView === null) {
return null;
}
const pointerEvents = offset !== 0 || isStale ? 'none' : 'box-none';
// On iOS, width of left/right components depends on the calculated
// size of the title.
const onLayoutIOS = name === 'title'
? (e: LayoutEvent) => {
this.setState({
widths: {
...this.state.widths,
[index]: e.nativeEvent.layout.width,
},
});
}
: undefined;
const titleWidth = name === 'left' || name === 'right'
? this.state.widths[index]
: undefined;
return (
<Animated.View
pointerEvents={pointerEvents}
onLayout={onLayoutIOS}
key={`${name}_${key}`}
style={[
titleWidth && {
width: (props.layout.initWidth - titleWidth) / 2,
},
styles.item,
styles[name],
styleInterpolator(props),
@ -232,61 +254,55 @@ class Header extends React.Component<void, HeaderProps, void> {
);
}
render(): React.Element<*> {
_renderHeader(props: NavigationSceneRendererProps): React.Element<*> {
const left = this._renderLeft(props);
const right = this._renderRight(props);
const title = this._renderTitle(props);
return (
<View
style={[StyleSheet.absoluteFill, styles.header]}
key={`scene_${props.scene.key}`}
>
{left}
{title}
{right}
</View>
);
}
render() {
let appBar;
if (this.props.mode === 'float') {
const scenesProps: Array<NavigationSceneRendererProps> = this.props.scenes
.map((scene: NavigationScene, index: number) => ({
...NavigationPropTypes.extractSceneRendererProps(this.props),
scene,
index,
navigation: addNavigationHelpers({
...this.props.navigation,
state: scene.route,
}),
}));
appBar = scenesProps.map(this._renderHeader, this);
} else {
appBar = this._renderHeader({
...this.props,
position: new Animated.Value(this.props.scene.index),
progress: new Animated.Value(0),
});
}
// eslint-disable-next-line no-unused-vars
const { scenes, scene, style, position, progress, ...rest } = this.props;
let children = null;
if (this.props.mode === 'float') {
// eslint-disable-next-line no-shadow
const scenesProps = (scenes.map((scene: NavigationScene, index: number) => {
const props = NavigationPropTypes.extractSceneRendererProps(this.props);
props.scene = scene;
props.index = index;
props.navigation = addNavigationHelpers({
...this.props.navigation,
state: scene.route,
});
return props;
}): Array<NavigationSceneRendererProps>);
const leftComponents = scenesProps.map(this._renderLeft, this);
const rightComponents = scenesProps.map(this._renderRight, this);
const titleComponents = scenesProps.map((props: *, i: number) =>
this._renderTitle(props, {
hasLeftComponent: leftComponents && !!leftComponents[i],
hasRightComponent: rightComponents && !!rightComponents[i],
})
);
children = [
titleComponents,
leftComponents,
rightComponents
];
} else {
const staticRendererProps = {
...this.props,
position: new Animated.Value(scene.index),
progress: new Animated.Value(0),
};
const leftComponent = this._renderLeft(staticRendererProps);
const rightComponent = this._renderRight(staticRendererProps);
const titleComponent = this._renderTitle(staticRendererProps, {
hasLeftComponent: !!leftComponent,
hasRightComponent: !!rightComponent,
});
children = [
titleComponent,
leftComponent,
rightComponent
];
}
return (
<Animated.View {...rest} style={[styles.container, style]}>
<View style={styles.appBar}>{children}</View>
<View style={styles.appBar}>
{appBar}
</View>
</Animated.View>
);
}
@ -295,8 +311,8 @@ class Header extends React.Component<void, HeaderProps, void> {
const styles = StyleSheet.create({
container: {
paddingTop: STATUSBAR_HEIGHT,
height: STATUSBAR_HEIGHT + APPBAR_HEIGHT,
backgroundColor: Platform.OS === 'ios' ? '#EFEFF2' : '#FFF',
height: STATUSBAR_HEIGHT + APPBAR_HEIGHT,
shadowColor: 'black',
shadowOpacity: 0.1,
shadowRadius: StyleSheet.hairlineWidth,
@ -306,30 +322,25 @@ const styles = StyleSheet.create({
elevation: 4,
},
appBar: {
flex: 1
flex: 1,
},
header: {
flexDirection: 'row',
},
item: {
flexDirection: 'row',
alignItems: 'center',
},
title: {
bottom: 0,
left: 40,
position: 'absolute',
right: 40,
top: 0,
justifyContent: 'center',
},
title: Platform.OS === 'android'
? {
flex: 1,
alignItems: 'flex-start',
}
: null,
left: {
bottom: 0,
left: 0,
position: 'absolute',
top: 0,
alignItems: 'flex-start',
},
right: {
bottom: 0,
position: 'absolute',
right: 0,
top: 0,
alignItems: 'flex-end',
},
});

View File

@ -10,54 +10,107 @@ import {
StyleSheet,
} from 'react-native';
import type { LayoutEvent } from '../TypeDefinition';
import TouchableItem from './TouchableItem';
type Props = {
onPress?: () => void,
title?: string,
tintColor?: ?string;
title?: ?string,
tintColor?: ?string,
truncatedTitle?: ?string,
};
const HeaderBackButton = ({ onPress, title, tintColor }: Props) => (
<TouchableItem
delayPressIn={0}
onPress={onPress}
style={styles.container}
borderless
>
<View style={styles.container}>
<Image
style={[
styles.icon,
title && styles.iconWithTitle,
{ tintColor },
]}
source={require('./assets/back-icon.png')}
/>
{Platform.OS === 'ios' && title && (
<Text style={[styles.title, { color: tintColor }]}>
{title}
</Text>
)}
</View>
</TouchableItem>
);
HeaderBackButton.propTypes = {
onPress: PropTypes.func.isRequired,
tintColor: PropTypes.string,
type DefaultProps = {
tintColor: ?string,
truncatedTitle: ?string,
};
HeaderBackButton.defaultProps = {
tintColor: Platform.select({
ios: '#037aff',
}),
type State = {
containerWidth?: number,
initialTextWidth?: number,
};
class HeaderBackButton extends React.PureComponent<DefaultProps, Props, State> {
static propTypes = {
onPress: PropTypes.func.isRequired,
title: PropTypes.string,
tintColor: PropTypes.string,
truncatedTitle: PropTypes.string,
};
static defaultProps = {
tintColor: Platform.select({
ios: '#037aff',
}),
truncatedTitle: 'Back',
};
state = {};
_onContainerLayout = (e: LayoutEvent) => {
if (Platform.OS !== 'ios') {
return;
}
this.setState({
containerWidth: e.nativeEvent.layout.width,
});
};
_onTextLayout = (e: LayoutEvent) => {
if (this.state.initialTextWidth) {
return;
}
this.setState({
initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
});
};
render() {
const { onPress, title, tintColor, truncatedTitle } = this.props;
const renderTruncated = this.state.containerWidth && this.state.initialTextWidth
? this.state.containerWidth < this.state.initialTextWidth
: false;
return (
<TouchableItem
delayPressIn={0}
onPress={onPress}
style={styles.container}
borderless
>
<View
onLayout={this._onContainerLayout}
style={styles.container}
>
<Image
style={[
styles.icon,
title && styles.iconWithTitle,
{ tintColor },
]}
source={require('./assets/back-icon.png')}
/>
{Platform.OS === 'ios' && title && (
<Text
ellipsizeMode="middle"
onLayout={this._onTextLayout}
style={[styles.title, { color: tintColor }]}
numberOfLines={1}
>
{renderTruncated ? truncatedTitle : title}
</Text>
)}
</View>
</TouchableItem>
);
}
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
title: {

View File

@ -23,7 +23,6 @@ const HeaderTitle = ({ style, ...rest }: Props) => (
const styles = StyleSheet.create({
title: {
flex: 1,
fontSize: Platform.OS === 'ios' ? 17 : 18,
fontWeight: Platform.OS === 'ios' ? '600' : '500',
color: 'rgba(0, 0, 0, .9)',