Smoothly transition header visibility in Stack

This closes #2732 (which also happens to be the top issue on canny.io).
This commit is contained in:
Adam Miskiewicz
2018-03-25 10:27:59 -07:00
committed by Brent Vatne
parent 828e7f2d43
commit cfc9690326
5 changed files with 407 additions and 150 deletions

View File

@@ -26,8 +26,11 @@ class HomeScreen extends React.Component<NavScreenProps> {
onPress={() => navigation.push('Other')}
title="Push another screen"
/>
<Button onPress={() => navigation.pop()} title="Pop" />
<Button onPress={() => navigation.goBack(null)} title="Go back" />
<Button
onPress={() => navigation.push('ScreenWithNoHeader')}
title="Push screen with no header"
/>
<Button onPress={() => navigation.goBack(null)} title="Go Home" />
<StatusBar barStyle="default" />
</SafeAreaView>
);
@@ -39,6 +42,51 @@ class OtherScreen extends React.Component<NavScreenProps> {
title: 'Your title here',
};
render() {
const { navigation } = this.props;
return (
<SafeAreaView style={{ paddingTop: 30 }}>
<Button
onPress={() => navigation.push('ScreenWithLongTitle')}
title="Push another screen"
/>
<Button
onPress={() => navigation.push('ScreenWithNoHeader')}
title="Push screen with no header"
/>
<Button onPress={() => navigation.pop()} title="Pop" />
<Button onPress={() => navigation.goBack(null)} title="Go back" />
<StatusBar barStyle="default" />
</SafeAreaView>
);
}
}
class ScreenWithLongTitle extends React.Component<NavScreenProps> {
static navigationOptions = {
title: "Another title that's kind of long",
};
render() {
const { navigation } = this.props;
return (
<SafeAreaView style={{ paddingTop: 30 }}>
<Button onPress={() => navigation.pop()} title="Pop" />
<Button onPress={() => navigation.goBack(null)} title="Go back" />
<StatusBar barStyle="default" />
</SafeAreaView>
);
}
}
class ScreenWithNoHeader extends React.Component<NavScreenProps> {
static navigationOptions = {
header: null,
title: 'No Header',
};
render() {
const { navigation } = this.props;
@@ -60,6 +108,8 @@ const StackWithHeaderPreset = createStackNavigator(
{
Home: HomeScreen,
Other: OtherScreen,
ScreenWithNoHeader: ScreenWithNoHeader,
ScreenWithLongTitle: ScreenWithLongTitle,
},
{
headerTransitionPreset: 'uikit',

View File

@@ -51,6 +51,7 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
"backgroundColor": "#E9E9EF",
"bottom": 0,
"left": 0,
"marginTop": 0,
"opacity": 1,
"position": "absolute",
"right": 0,
@@ -77,36 +78,31 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
<View
collapsable={undefined}
onLayout={[Function]}
pointerEvents="box-none"
style={
Object {
"backgroundColor": "red",
"borderBottomColor": "#A7A7AA",
"borderBottomWidth": 0.5,
"height": 64,
"opacity": 0.5,
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 20,
"transform": Array [
Object {
"translateX": 0,
},
],
}
}
>
<View
collapsable={undefined}
onLayout={[Function]}
pointerEvents="box-none"
style={
Object {
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
<View
style={
Object {
"flex": 1,
"backgroundColor": "red",
"borderBottomColor": "#A7A7AA",
"borderBottomWidth": 0.5,
"height": 64,
"opacity": 0.5,
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 20,
}
}
>
@@ -114,70 +110,89 @@ exports[`StackNavigator applies correct values when headerRight is present 1`] =
style={
Object {
"bottom": 0,
"flexDirection": "row",
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<View
collapsable={undefined}
pointerEvents="box-none"
style={
Object {
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"justifyContent": "center",
"left": 70,
"opacity": 1,
"position": "absolute",
"right": 70,
"top": 0,
}
}
>
<Text
accessibilityTraits="header"
accessible={true}
allowFontScaling={true}
collapsable={undefined}
ellipsizeMode="tail"
numberOfLines={1}
onLayout={[Function]}
style={
Object {
"color": "rgba(0, 0, 0, .9)",
"fontSize": 17,
"fontWeight": "700",
"marginHorizontal": 16,
"textAlign": "center",
}
}
>
Welcome anonymous
</Text>
</View>
<View
collapsable={undefined}
pointerEvents="box-none"
style={
Object {
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"opacity": 1,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<View />
<View
collapsable={undefined}
pointerEvents="box-none"
style={
Object {
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"justifyContent": "center",
"left": 70,
"opacity": 1,
"position": "absolute",
"right": 70,
"top": 0,
}
}
>
<Text
accessibilityTraits="header"
accessible={true}
allowFontScaling={true}
collapsable={undefined}
ellipsizeMode="tail"
numberOfLines={1}
onLayout={[Function]}
style={
Object {
"color": "rgba(0, 0, 0, .9)",
"fontSize": 17,
"fontWeight": "700",
"marginHorizontal": 16,
"textAlign": "center",
}
}
>
Welcome anonymous
</Text>
</View>
<View
collapsable={undefined}
pointerEvents="box-none"
style={
Object {
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"opacity": 1,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<View />
</View>
</View>
</View>
</View>
@@ -237,6 +252,7 @@ exports[`StackNavigator renders successfully 1`] = `
"backgroundColor": "#E9E9EF",
"bottom": 0,
"left": 0,
"marginTop": 0,
"opacity": 1,
"position": "absolute",
"right": 0,
@@ -263,36 +279,31 @@ exports[`StackNavigator renders successfully 1`] = `
<View
collapsable={undefined}
onLayout={[Function]}
pointerEvents="box-none"
style={
Object {
"backgroundColor": "red",
"borderBottomColor": "#A7A7AA",
"borderBottomWidth": 0.5,
"height": 64,
"opacity": 0.5,
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 20,
"transform": Array [
Object {
"translateX": 0,
},
],
}
}
>
<View
collapsable={undefined}
onLayout={[Function]}
pointerEvents="box-none"
style={
Object {
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
<View
style={
Object {
"flex": 1,
"backgroundColor": "red",
"borderBottomColor": "#A7A7AA",
"borderBottomWidth": 0.5,
"height": 64,
"opacity": 0.5,
"paddingBottom": 0,
"paddingLeft": 0,
"paddingRight": 0,
"paddingTop": 20,
}
}
>
@@ -300,52 +311,71 @@ exports[`StackNavigator renders successfully 1`] = `
style={
Object {
"bottom": 0,
"flexDirection": "row",
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<View
collapsable={undefined}
pointerEvents="box-none"
style={
Object {
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"justifyContent": "center",
"left": 0,
"opacity": 1,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<Text
accessibilityTraits="header"
accessible={true}
allowFontScaling={true}
<View
collapsable={undefined}
ellipsizeMode="tail"
numberOfLines={1}
onLayout={[Function]}
pointerEvents="box-none"
style={
Object {
"color": "rgba(0, 0, 0, .9)",
"fontSize": 17,
"fontWeight": "700",
"marginHorizontal": 16,
"textAlign": "center",
"alignItems": "center",
"backgroundColor": "transparent",
"bottom": 0,
"flexDirection": "row",
"justifyContent": "center",
"left": 0,
"opacity": 1,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
Welcome anonymous
</Text>
<Text
accessibilityTraits="header"
accessible={true}
allowFontScaling={true}
collapsable={undefined}
ellipsizeMode="tail"
numberOfLines={1}
onLayout={[Function]}
style={
Object {
"color": "rgba(0, 0, 0, .9)",
"fontSize": 17,
"fontWeight": "700",
"marginHorizontal": 16,
"textAlign": "center",
}
}
>
Welcome anonymous
</Text>
</View>
</View>
</View>
</View>

View File

@@ -30,6 +30,7 @@ const getAppBarHeight = isLandscape => {
class Header extends React.PureComponent {
static defaultProps = {
layoutInterpolator: HeaderStyleInterpolator.forLayout,
leftInterpolator: HeaderStyleInterpolator.forLeft,
leftButtonInterpolator: HeaderStyleInterpolator.forLeftButton,
leftLabelInterpolator: HeaderStyleInterpolator.forLeftLabel,
@@ -46,6 +47,12 @@ class Header extends React.PureComponent {
widths: {},
};
_handleOnLayout = e => {
if (typeof this.props.onLayout === 'function') {
this.props.onLayout(e.nativeEvent.layout);
}
};
_getHeaderTitleString(scene) {
const options = scene.descriptor.options;
if (typeof options.headerTitle === 'string') {
@@ -370,6 +377,10 @@ class Header extends React.PureComponent {
}
_renderHeader(props) {
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, {
@@ -378,7 +389,6 @@ class Header extends React.PureComponent {
});
const { isLandscape, transitionPreset } = this.props;
const { options } = props.scene.descriptor;
const wrapperProps = {
style: styles.header,
@@ -484,10 +494,17 @@ class Header extends React.PureComponent {
const forceInset = headerForceInset || { top: 'always', bottom: 'never' };
return (
<SafeAreaView forceInset={forceInset} style={containerStyles}>
<View style={StyleSheet.absoluteFill}>{options.headerBackground}</View>
<View style={styles.flexOne}>{appBar}</View>
</SafeAreaView>
<Animated.View
style={this.props.layoutInterpolator(this.props)}
onLayout={this._handleOnLayout}
>
<SafeAreaView forceInset={forceInset} style={containerStyles}>
<View style={StyleSheet.absoluteFill}>
{options.headerBackground}
</View>
<View style={styles.flexOne}>{appBar}</View>
</SafeAreaView>
</Animated.View>
);
}
}

View File

@@ -1,13 +1,37 @@
import { Dimensions, I18nManager } from 'react-native';
import getSceneIndicesForInterpolationInputRange from '../../utils/getSceneIndicesForInterpolationInputRange';
const crossFadeInterpolation = (first, index, last) => ({
inputRange: [first, index - 0.9, index - 0.2, index, last],
outputRange: [0, 0, 0.3, 1, 0],
function hasHeader(scene) {
if (!scene) {
return true;
}
const { descriptor } = scene;
return descriptor.options.header !== null;
}
const crossFadeInterpolation = (scenes, first, index, last) => ({
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,
],
});
/**
* Utility that builds the style for the navigation header.
* Utilities that build the style for the navigation header.
*
* +-------------+-------------+-------------+
* | | | |
@@ -17,6 +41,37 @@ const crossFadeInterpolation = (first, index, last) => ({
* +-------------+-------------+-------------+
*/
function isGoingBack(scenes) {
const lastSceneIndexInScenes = scenes.length - 1;
return !scenes[lastSceneIndexInScenes].isActive;
}
function forLayout(props) {
const { layout, position, scene, scenes } = props;
const isBack = isGoingBack(scenes);
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return {};
const { first, last } = interpolate;
const index = scene.index;
const width = layout.initWidth;
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),
],
});
return {
transform: [{ translateX }],
};
}
function forLeft(props) {
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
@@ -27,12 +82,14 @@ function forLeft(props) {
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
opacity: position.interpolate(
crossFadeInterpolation(scenes, first, index, last)
),
};
}
function forCenter(props) {
const { position, scene } = props;
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
@@ -41,12 +98,14 @@ function forCenter(props) {
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
opacity: position.interpolate(
crossFadeInterpolation(scenes, first, index, last)
),
};
}
function forRight(props) {
const { position, scene } = props;
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
@@ -54,7 +113,9 @@ function forRight(props) {
const index = scene.index;
return {
opacity: position.interpolate(crossFadeInterpolation(first, index, last)),
opacity: position.interpolate(
crossFadeInterpolation(scenes, first, index, last)
),
};
}
@@ -71,22 +132,39 @@ function forLeftButton(props) {
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.1 : 1,
hasHeader(scenes[index]) ? 1 : 0,
hasHeader(scenes[last]) ? 0.1 : 1,
hasHeader(scenes[last]) ? 0 : 1,
0,
];
return {
opacity: position.interpolate({
inputRange: [
first,
first + Math.abs(index - first) / 2,
index,
last - Math.abs(last - index) / 2,
last,
],
outputRange: [0, 0.5, 1, 0.5, 0],
inputRange,
outputRange,
}),
};
}
/*
* NOTE: this offset calculation is a an approximation that gives us
* 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.
*
@@ -105,21 +183,51 @@ function forLeftLabel(props) {
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, index - 0.35, index, index + 0.5, last],
outputRange: [0, 0, 1, 0.5, 0],
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,
],
}),
transform: [
{
translateX: position.interpolate({
inputRange: [first, index, last],
inputRange: [first, first + 0.001, index, last - 0.001, last],
outputRange: I18nManager.isRTL
? [-offset, 0, offset]
: [offset, 0, -offset * 1.5],
? [
-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,
],
}),
},
],
@@ -139,7 +247,7 @@ function forLeftLabel(props) {
*/
const TITLE_OFFSET_IOS = Dimensions.get('window').width / 2 - 70 + 25;
function forCenterFromLeft(props) {
const { position, scene } = props;
const { position, scene, scenes } = props;
const interpolate = getSceneIndicesForInterpolationInputRange(props);
if (!interpolate) return { opacity: 0 };
@@ -151,16 +259,44 @@ function forCenterFromLeft(props) {
return {
opacity: position.interpolate({
inputRange: [first, index - 0.5, index, index + 0.7, last],
outputRange: [0, 0, 1, 0, 0],
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,
],
}),
transform: [
{
translateX: position.interpolate({
inputRange: [first, index, last],
inputRange: [first, first + 0.001, index, last - 0.001, last],
outputRange: I18nManager.isRTL
? [-offset, 0, offset]
: [offset, 0, -offset],
? [
-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,
],
}),
},
],
@@ -168,6 +304,7 @@ function forCenterFromLeft(props) {
}
export default {
forLayout,
forLeft,
forLeftButton,
forLeftLabel,

View File

@@ -83,11 +83,22 @@ class StackViewLayout extends React.Component {
const { options } = scene.descriptor;
const { header } = options;
if (typeof header !== 'undefined' && typeof header !== 'function') {
if (header === null && headerMode === 'screen') {
return null;
}
// check if it's a react element
if (React.isValidElement(header)) {
return header;
}
const renderHeader = header || ((props: *) => <Header {...props} />);
// Handle the case where the header option is a function, and provide the default
const renderHeader =
header ||
(props => (
<Header onLayout={layout => (this._headerLayout = layout)} {...props} />
));
const {
headerLeftInterpolator,
headerTitleInterpolator,
@@ -167,6 +178,7 @@ class StackViewLayout extends React.Component {
immediate: true,
})
);
navigation.dispatch(NavigationActions.completeTransition());
}
};
@@ -433,11 +445,22 @@ class StackViewLayout extends React.Component {
screenInterpolator &&
screenInterpolator({ ...this.props.transitionProps, scene });
// If this screen has "header" set to `null` in it's navigation options, but
// it exists in a stack with headerMode float, add a negative margin to
// compensate for the hidden header
const { options } = scene.descriptor;
const hasHeader = options.header !== null;
const headerMode = this._getHeaderMode();
let marginTop = 0;
if (!hasHeader && headerMode === 'float' && this._headerLayout) {
marginTop = -this._headerLayout.height;
}
return (
<Card
{...this.props.transitionProps}
key={`card_${scene.key}`}
style={[style, this.props.cardStyle]}
style={[style, { marginTop }, this.props.cardStyle]}
scene={scene}
>
{this._renderInnerScene(scene)}