Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jordanmkoncz/3d6ea2773399916afe1c9f58a17e6931 to your computer and use it in GitHub Desktop.
Save jordanmkoncz/3d6ea2773399916afe1c9f58a17e6931 to your computer and use it in GitHub Desktop.
react-navigation iOS 11 Navigation Bar with Large Title

This gist provides an example of how to implement the iOS 11 Navigation Bar with Large Title for react-navigation. For more information on this navigation bar style, see https://medium.com/@PavelGnatyuk/large-title-and-search-in-ios-11-514d5e020cee and react-navigation/react-navigation#2749.

You can check out https://i.imgur.com/M8pv1ya.png to see an example of how this looks in an app where I'm using all of the components provided in this gist.

Notes:

  • I've used the react-native-typography library as the source of the correct text styles for the header (title and left/right components) to match the text styles of the native iOS 11 Navigation Bar with Large Title.
  • I have intentionally made is to that the header looks the same on both iOS and Android (aside from minor differences like the fonts used on iOS vs Android). This is what I needed for my use case, but if you wanted to render headers differently on Android you'd have to implement those changes for how the header renders on Android.
  • I have provided the relevant dependencies from my package.json file. The versions you see in this file are the ones I'm currently using and that I've tested with. I haven't tested with newer versions of react-native or react-navigation, so it's possible that there are some changes required to support those newer versions.
  • In the cases where I've copied a component from react-navigation (e.g. Header.js) and then modified it, I've tried to leave comments explaining the changes I've made. This should make it easier to retain or implement the same changes in the situation where there is a new version of react-navigation which contains significant changes to the component I originally copied.
  • You will need to copy the iOS back icon images from react-navigation into your own project source folder (so that the back button looks the same on Android as it does on iOS). See the comments in the HeaderLargeBackButton.js file for more info on this.

Potential improvements:

  • Make the header automatically shrink/expand based on the user scrolling up/down on the screen. This is how the native iOS 11 Navigation Bar with Large Title behaves, but I have not tried to implement this same behaviour yet.
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { addNavigationHelpers, StackNavigator } from 'react-navigation';
import ScreenExample from './ScreenExample';
export const NavigatorExample = StackNavigator(
{
'ScreenExample': { screen: ScreenExample },
},
{
navigationOptions: {
// You can customise the colours used in the header by changing these values.
headerStyle: {
backgroundColor: '#F7F7F7',
borderBottomColor: 'rgba(0, 0, 0, .3)',
},
headerTintColor: 'rgba(0, 0, 0, .9)',
},
}
);
class AppNavigator extends Component {
static propTypes = {
navState: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
render() {
const { dispatch, navState } = this.props;
return <NavigatorExample navigation={addNavigationHelpers({ dispatch, state: navState })} />;
}
}
export default AppNavigator;
import React, { Component } from 'react';
import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from 'react-native';
import PropTypes from 'prop-types';
let styles;
class HeaderButton extends Component {
static propTypes = {
children: PropTypes.node,
loading: PropTypes.bool,
disabled: PropTypes.bool,
handlePress: PropTypes.func.isRequired,
tintColor: PropTypes.any,
};
static defaultProps = {
children: null,
loading: false,
disabled: false,
tintColor: '#037aff',
};
render() {
const { children, loading, disabled, handlePress, tintColor } = this.props;
return (
<TouchableOpacity onPress={handlePress} disabled={disabled || loading}>
<View style={styles.container}>
{!loading && children}
{loading && <ActivityIndicator color={tintColor} style={{ backgroundColor: 'transparent' }} />}
</View>
</TouchableOpacity>
);
}
}
styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
// Match left spacing of HeaderLargeBackButton.
paddingHorizontal: 10,
// Match height of HeaderLargeBackButton.
height: 21 + 12 + 12,
},
});
export default HeaderButton;
import React, { Component } from 'react';
import { StyleSheet, Text } from 'react-native';
import { iOSUIKit } from 'react-native-typography';
import HeaderButton from './HeaderButton';
let styles;
class HeaderButtonExample extends Component {
constructor() {
super();
this.handlePress = this.handlePress.bind(this);
}
handlePress() {
console.log('Pressed header button.');
}
render() {
return (
<HeaderButton handlePress={this.handlePress}>
<Text style={[styles.titleText, { color: this.props.tintColor }]}>Header Button</RegularText>
</HeaderButton>
);
}
}
styles = StyleSheet.create({
titleText: {
// The `iOSUIKit.bodyObject` styles are required to make the header button text match the iOS 11 Navigation Bar with
// Large Title styles.
...iOSUIKit.bodyObject,
},
});
export default HeaderButtonExample;
/* @flow */
import * as React from 'react';
import { Animated, StyleSheet, View, ViewPropTypes } from 'react-native';
import { HeaderTitle, SafeAreaView } from 'react-navigation';
import HeaderStyleInterpolator from 'react-navigation/src/views/Header/HeaderStyleInterpolator';
import withOrientation from 'react-navigation/src/views/withOrientation';
import type { NavigationScene, NavigationStyleInterpolator, HeaderProps } from 'react-navigation/src/TypeDefinition';
import { iOSUIKit } from 'react-native-typography';
import HeaderLargeBackButton from './HeaderLargeBackButton';
type SceneProps = {
scene: NavigationScene,
position: Animated.Value,
progress: Animated.Value,
style?: ViewPropTypes.style,
};
type SubViewRenderer<T> = (props: SceneProps) => ?React.Node;
type SubViewName = 'left' | 'title' | 'right';
type Props = HeaderProps & { isLandscape: boolean };
let styles;
/**
* Copied from react-navigation/src/views/Header/Header.
*
* Modified to match the styles of an iOS 11 Navigation Bar with Large Title.
*/
class HeaderLarge extends React.PureComponent<Props> {
_getHeaderTitleString(scene: NavigationScene): ?string {
const sceneOptions = this.props.getScreenDetails(scene).options;
if (typeof sceneOptions.headerTitle === 'string') {
return sceneOptions.headerTitle;
}
return sceneOptions.title;
}
_getLastScene(scene: NavigationScene): ?NavigationScene {
return this.props.scenes.find((s: *) => s.index === scene.index - 1);
}
_getBackButtonTitleString(scene: NavigationScene): ?string {
const lastScene = this._getLastScene(scene);
if (!lastScene) {
return null;
}
const { headerBackTitle } = this.props.getScreenDetails(lastScene).options;
if (headerBackTitle || headerBackTitle === null) {
return headerBackTitle;
}
return this._getHeaderTitleString(lastScene);
}
_navigateBack = () => {
this.props.navigation.goBack(null);
};
_renderTitleComponent = (props: SceneProps): ?React.Node => {
/**
* Modified to ignore truncation-related functionality (onLayout width calculation). Also modified to pass down
* a `tintColor` prop when `options.headerTitle` is a valid element. Also modified so that `RenderedHeaderTitle`
* has styles applied to make it match the iOS 11 Navigation Bar with Large Title styles, and has the `color`
* style specified last to ensure that the `options.headerTintColor` value is applied.
*/
// $FlowFixMe
const { options } = this.props.getScreenDetails(props.scene);
const { headerTitle } = options;
if (React.isValidElement(options.headerTitle)) {
return React.cloneElement(options.headerTitle, { tintColor: options.headerTintColor });
}
const titleString = this._getHeaderTitleString(props.scene);
const tintColor = options.headerTintColor;
const allowFontScaling = options.headerTitleAllowFontScaling;
const RenderedHeaderTitle = headerTitle && typeof headerTitle !== 'string' ? headerTitle : HeaderTitle;
return (
<RenderedHeaderTitle
allowFontScaling={allowFontScaling == null ? true : allowFontScaling}
style={[styles.titleText, options.headerTitleStyle, !!tintColor && { color: tintColor }]}
>
{titleString}
</RenderedHeaderTitle>
);
};
_renderLeftComponent = (props: SceneProps): ?React.Node => {
/**
* Modified to ignore truncation-related functionality (truncated title, limited width), and to use our custom
* HeaderLargeBackButton when a headerLeft is not specified. Also modified to pass down a `tintColor` prop when
* `options.headerLeft` is a valid element.
*/
// $FlowFixMe
const { options } = this.props.getScreenDetails(props.scene);
if (React.isValidElement(options.headerLeft)) {
return React.cloneElement(options.headerLeft, { tintColor: options.headerTintColor });
}
if (options.headerLeft === null) {
return options.headerLeft;
}
if (props.scene.index === 0) {
return null;
}
const backButtonTitle = this._getBackButtonTitleString(props.scene);
const RenderedLeftComponent = options.headerLeft || HeaderLargeBackButton;
return (
<RenderedLeftComponent
onPress={this._navigateBack}
tintColor={options.headerTintColor}
title={backButtonTitle}
titleStyle={options.headerBackTitleStyle}
/>
);
};
_renderRightComponent = (props: SceneProps): ?React.Node => {
/**
* Modified to pass down a `tintColor` prop when `options.headerRight` is a valid element.
*/
const { options } = this.props.getScreenDetails(props.scene);
if (React.isValidElement(options.headerRight)) {
return React.cloneElement(options.headerRight, { tintColor: options.headerTintColor });
}
return options.headerRight || null;
};
_renderLeft(props: SceneProps): ?React.Node {
/**
* Modified to use `HeaderStyleInterpolator.forRight` for consistency between headerLeft and headerRight
* transition styles.
*/
return this._renderSubView(props, 'left', this._renderLeftComponent, HeaderStyleInterpolator.forRight);
}
_renderTitle(props: SceneProps, options: *): ?React.Node {
/**
* Modified to ignore styles relating to absolute positioning of title.
*/
return this._renderSubView(props, 'title', this._renderTitleComponent, HeaderStyleInterpolator.forCenter);
}
_renderRight(props: SceneProps): ?React.Node {
return this._renderSubView(props, 'right', this._renderRightComponent, HeaderStyleInterpolator.forRight);
}
_renderSubView<T>(
props: SceneProps,
name: SubViewName,
renderer: SubViewRenderer<T>,
styleInterpolator: NavigationStyleInterpolator
): ?React.Node {
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({
// todo: determine if we really need to splat all this.props
...this.props,
...props,
}),
]}
>
{subView}
</Animated.View>
);
}
_renderHeader(props: SceneProps): React.Node {
/**
* Modified to change the header layout by wrapping header actions in a styled View, which is separate from the
* title.
*/
let left = this._renderLeft(props);
const right = this._renderRight(props);
const title = this._renderTitle(props, {
hasLeftComponent: !!left,
hasRightComponent: !!right,
});
// If we have a `headerRight` but don't have a `headerLeft`, we render an empty `View` as our `headerLeft` so that
// the `headerRight` will still appear on the right side of the header.
if (!left && !!right) {
left = <View />;
}
return (
<View style={[StyleSheet.absoluteFill, styles.header]} key={`scene_${props.scene.key}`}>
<View style={styles.headerLeftRightContainer}>
{left}
{right}
</View>
{title}
</View>
);
}
render() {
/**
* Modified to increase the app bar height to match the height of an iOS 11 Navigation Bar with Large Title.
*/
let appBar;
if (this.props.mode === 'float') {
const scenesProps: Array<SceneProps> = this.props.scenes.map((scene: NavigationScene) => ({
position: this.props.position,
progress: this.props.progress,
scene,
}));
appBar = scenesProps.map(this._renderHeader, this);
} else {
appBar = this._renderHeader({
position: new Animated.Value(this.props.scene.index),
progress: new Animated.Value(0),
scene: this.props.scene,
});
}
// eslint-disable-next-line no-unused-vars
const { scenes, scene, position, screenProps, progress, isLandscape, ...rest } = this.props;
const { options } = this.props.getScreenDetails(scene);
const { headerStyle } = options;
// Match the height of an iOS 11 Navigation Bar with Large Title.
const appBarHeight = 96;
const containerStyles = [
styles.container,
{
height: appBarHeight,
},
headerStyle,
];
return (
<Animated.View {...rest}>
<SafeAreaView style={containerStyles} forceInset={{ top: 'always', bottom: 'never' }}>
<View style={styles.appBar}>{appBar}</View>
</SafeAreaView>
</Animated.View>
);
}
}
/**
* Modified to change the header layout, and to make the bottom border stronger to provide more separation between
* header and content.
*/
styles = StyleSheet.create({
container: {
backgroundColor: '#F7F7F7',
borderBottomWidth: 1,
borderBottomColor: 'rgba(0, 0, 0, .3)',
},
appBar: {
flex: 1,
},
header: {
flex: 1,
flexDirection: 'column',
},
headerLeftRightContainer: {
marginHorizontal: 10,
flexDirection: 'row',
justifyContent: 'space-between',
// Match height of HeaderLargeBackButton. This is needed so that when we don't have a `headerLeft` or `headerRight`
// we maintain a consistent header layout (without setting a height here, the `title` is not positioned correctly).
// There is probably a better solution for this.
height: 21 + 12 + 12,
},
item: {
justifyContent: 'center',
alignItems: 'flex-start',
backgroundColor: 'transparent',
},
title: {
marginHorizontal: 20,
marginBottom: 10,
},
titleText: {
...iOSUIKit.largeTitleEmphasizedObject,
marginHorizontal: 0,
textAlign: 'left',
},
left: {
flex: 1,
paddingRight: 20,
},
right: {},
});
export default withOrientation(HeaderLarge);
/* @flow */
import React, { PureComponent } from 'react';
import { I18nManager, Image, Text, TouchableOpacity, View, StyleSheet } from 'react-native';
import type { TextStyleProp } from 'react-navigation/src/TypeDefinition';
import { iOSUIKit } from 'react-native-typography';
type Props = {
onPress?: () => void,
title?: ?string,
titleStyle?: ?TextStyleProp,
tintColor?: ?string,
};
let styles;
/**
* Copied from react-navigation/src/views/Header/HeaderBackButton.
*
* The component has been modified so that it is displayed the same on both iOS and Android. It has been modified to
* fill the entire container width, instead of shrinking to fit a small container width based on being displayed
* alongside the header title. The title styles have been modified to use the appropriate styles from
* `react-native-typography`.
*/
class HeaderLargeBackButton extends PureComponent<Props> {
static defaultProps = {
tintColor: '#037aff',
};
render() {
const { onPress, title, titleStyle, tintColor } = this.props;
const backButtonTitle = title;
// Use the `back-icon.png` image(s) from our own project source folder. Note that these are just the back icon
// images for iOS copied from react-navigation/src/views/assets/ and renamed so that the iOS back icon image will be
// used on both iOS and Android. You will need to create a new folder within your project source folder, and then
// copy the `back-icon.png` file and also the files ending in `.ios.png`, and then rename these files so that they
// just end in `.png`. For example, you'd copy `[email protected]`, and rename it to `[email protected]`.
// eslint-disable-next-line global-require
const asset = require('../assets/react-navigation/back-icon.png');
return (
<TouchableOpacity
accessibilityComponentType="button"
accessibilityLabel={backButtonTitle}
accessibilityTraits="button"
testID="header-back"
onPress={onPress}
style={styles.container}
>
<View style={styles.contentContainer}>
<Image style={[styles.icon, !!title && styles.iconWithTitle, !!tintColor && { tintColor }]} source={asset} />
{typeof backButtonTitle === 'string' && (
<Text style={[styles.title, titleStyle, !!tintColor && { color: tintColor }]} numberOfLines={1}>
{backButtonTitle}
</Text>
)}
</View>
</TouchableOpacity>
);
}
}
styles = StyleSheet.create({
container: {
alignSelf: 'flex-start',
alignItems: 'center',
backgroundColor: 'transparent',
},
contentContainer: {
alignItems: 'center',
flexDirection: 'row',
backgroundColor: 'transparent',
},
title: {
...iOSUIKit.bodyObject,
paddingRight: 20,
},
icon: {
height: 21,
width: 13,
marginLeft: 10,
marginRight: 22,
marginVertical: 12,
resizeMode: 'contain',
transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }],
},
iconWithTitle: {
marginRight: 5,
},
});
export default HeaderLargeBackButton;
{
"name": "react-navigation iOS 11 Navigation Bar with Large Title Example",
"version": "1.0.0",
"private": true,
"devDependencies": {},
"scripts": {},
"dependencies": {
"prop-types": "^15.6.0",
"react": "16.0.0",
"react-native": "0.50.3",
"react-native-typography": "1.0.3",
"react-navigation": "1.0.0-beta.21"
}
}
import React, { Component } from 'react';
import { Text } from 'react-native';
import HeaderLarge from './HeaderLarge';
import HeaderButtonExample from './HeaderButtonExample';
class ScreenExample extends Component {
static navigationOptions = {
title: 'Screen Example',
headerRight: <HeaderButtonExample />,
// Specify that this screen should use our custom HeaderLarge component for the `header`.
header: headerProps => <HeaderLarge {...headerProps} />,
};
render() {
return (
<Text>Hello World</Text>
);
}
}
export default ScreenExample;
@0x1ad2
Copy link

0x1ad2 commented Nov 25, 2018

Thanks for the example! I did got an error about closing the <Text> tag on https://gist.github.com/jordanmkoncz/3d6ea2773399916afe1c9f58a17e6931#file-headerbuttonexample-js-L22

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment