Skip to content
This repository has been archived by the owner on Nov 26, 2019. It is now read-only.

Commit

Permalink
Initial work on UIKit style animation for header
Browse files Browse the repository at this point in the history
  • Loading branch information
satya164 committed May 21, 2019
1 parent 5d25053 commit b57666d
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 51 deletions.
78 changes: 65 additions & 13 deletions src/components/Header/HeaderAnimated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,75 @@ type Props<T extends Route> = {
style?: StyleProp<ViewStyle>;
};

const { interpolate, multiply } = Animated;
const { interpolate, add } = Animated;

const UIKitPreset: HeaderAnimationPreset = {
styleInterpolator: ({ current, next, layout }: InterpolationProps) => {
/**
* 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 buttonAreaSize = 70;
const buttonIconSize = 25;
const titleOffset = layout.width / 2 - buttonAreaSize + buttonIconSize;
const backTitleOffset = layout.width / 2 - buttonAreaSize - buttonIconSize;

const progress = add(current, next ? next : 0);

return {
leftButtonStyle: {
opacity: interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [0, 1, 0],
}),
},
backTitleStyle: {
opacity: interpolate(progress, {
inputRange: [0.7, 1, 1.3],
outputRange: [0, 1, 0],
}),
transform: [
{
translateX: interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [backTitleOffset, 0, -backTitleOffset],
}),
},
],
},
titleStyle: {
opacity: interpolate(progress, {
inputRange: [0.5, 1, 1.7],
outputRange: [0, 1, 0],
}),
transform: [
{
translateX: interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [titleOffset, 0, -titleOffset],
}),
},
],
},
};
},
};

const FadePreset: HeaderAnimationPreset = {
styleInterpolator: ({ current, next }: InterpolationProps) => {
const progress = next
? multiply(
current,
interpolate(next, {
inputRange: [0, 1],
outputRange: [1, 0],
})
)
: current;
const progress = add(current, next ? next : 0);
const opacity = interpolate(progress, {
inputRange: [0, 1, 2],
outputRange: [0, 1, 0],
});

return {
leftButtonStyle: { opacity: progress },
titleStyle: { opacity: progress },
leftButtonStyle: { opacity },
titleStyle: { opacity },
};
},
};
Expand All @@ -54,7 +106,7 @@ export default class HeaderAnimated<T extends Route> extends React.Component<
Props<T>
> {
static defaultProps = {
preset: FadePreset,
preset: Platform.OS === 'ios' ? UIKitPreset : FadePreset,
};

render() {
Expand Down
44 changes: 37 additions & 7 deletions src/components/Header/HeaderAnimatedItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import {
View,
StyleSheet,
StyleProp,
ViewStyle,
LayoutChangeEvent,
} from 'react-native';
import Animated from 'react-native-reanimated';
import HeaderTitle from './HeaderTitle';
import { Route, Layout } from '../Stack';
Expand All @@ -15,8 +21,9 @@ export type InterpolationProps = {
export type StyleInterpolator = (
props: InterpolationProps
) => {
leftButtonStyle: any;
titleStyle: any;
backTitleStyle?: any;
leftButtonStyle?: any;
titleStyle?: any;
};

export type HeaderAnimationPreset = {
Expand All @@ -39,9 +46,15 @@ type Props<T extends Route> = {
style?: StyleProp<ViewStyle>;
};

type State = {
titleWidth?: number;
};

export default class HeaderAnimatedItem<
T extends Route
> extends React.Component<Props<T>> {
> extends React.Component<Props<T>, State> {
state: State = {};

private getInterpolatedStyle = memoize(
(
styleInterpolator: StyleInterpolator,
Expand All @@ -51,6 +64,9 @@ export default class HeaderAnimatedItem<
) => styleInterpolator({ current, next, layout })
);

private handleTitleLayout = (e: LayoutChangeEvent) =>
this.setState({ titleWidth: e.nativeEvent.layout.width });

render() {
const {
scene,
Expand All @@ -62,7 +78,13 @@ export default class HeaderAnimatedItem<
style,
} = this.props;

const { titleStyle, leftButtonStyle } = this.getInterpolatedStyle(
const { titleWidth } = this.state;

const {
titleStyle,
leftButtonStyle,
backTitleStyle,
} = this.getInterpolatedStyle(
preset.styleInterpolator,
layout,
scene.progress,
Expand All @@ -73,10 +95,18 @@ export default class HeaderAnimatedItem<
<View style={[styles.content, style]}>
{previous ? (
<Animated.View style={[styles.left, leftButtonStyle]}>
<HeaderBackButton onPress={onGoBack} title={previous.title} />
<HeaderBackButton
onPress={onGoBack}
title={previous.title}
titleStyle={backTitleStyle}
width={titleWidth}
/>
</Animated.View>
) : null}
<HeaderTitle style={[previous ? styles.title : null, titleStyle]}>
<HeaderTitle
onLayout={this.handleTitleLayout}
style={[previous ? styles.title : null, titleStyle]}
>
{scene.title}
</HeaderTitle>
</View>
Expand Down
92 changes: 67 additions & 25 deletions src/components/Header/HeaderBackButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,32 @@ import * as React from 'react';
import {
I18nManager,
Image,
Text,
View,
Platform,
StyleSheet,
LayoutChangeEvent,
StyleProp,
TextStyle,
Text,
MaskedViewIOS,
} from 'react-native';

import Animated from 'react-native-reanimated';
import TouchableItem from '../TouchableItem';

type Props = {
disabled?: boolean;
onPress: () => void;
pressColorAndroid?: string;
backImage?: React.ComponentType<{
tintColor: string;
title?: string | null;
}>;
backImage?: (props: { tintColor: string; title?: string }) => React.ReactNode;
tintColor: string;
title?: string | null;
truncatedTitle?: string | null;
title?: string;
truncatedTitle?: string;
backTitleVisible?: boolean;
allowFontScaling?: boolean;
titleStyle?: StyleProp<TextStyle>;
titleStyle?: React.ComponentProps<typeof Text>['style'];
width?: number;
};

type State = {
initialTextWidth?: number;
initialTitleWidth?: number;
};

class HeaderBackButton extends React.Component<Props, State> {
Expand All @@ -48,11 +44,12 @@ class HeaderBackButton extends React.Component<Props, State> {
state: State = {};

private handleTextLayout = (e: LayoutChangeEvent) => {
if (this.state.initialTextWidth) {
if (this.state.initialTitleWidth) {
return;
}

this.setState({
initialTextWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
initialTitleWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width,
});
};

Expand All @@ -62,9 +59,7 @@ class HeaderBackButton extends React.Component<Props, State> {
let title = this.getTitleText();

if (backImage) {
const BackImage = backImage;

return <BackImage tintColor={tintColor} title={title} />;
return backImage({ tintColor, title });
} else {
return (
<Image
Expand All @@ -83,10 +78,10 @@ class HeaderBackButton extends React.Component<Props, State> {
private getTitleText = () => {
const { width, title, truncatedTitle } = this.props;

let { initialTextWidth } = this.state;
let { initialTitleWidth: initialTextWidth } = this.state;

if (title === null) {
return null;
if (title == undefined) {
return undefined;
} else if (!title) {
return truncatedTitle;
} else if (initialTextWidth && width && initialTextWidth > width) {
Expand All @@ -100,25 +95,53 @@ class HeaderBackButton extends React.Component<Props, State> {
const {
allowFontScaling,
backTitleVisible,
backImage,
titleStyle,
tintColor,
} = this.props;
const { initialTitleWidth: titleWidth } = this.state;

let backTitleText = this.getTitleText();

if (!backTitleVisible || backTitleText === null) {
if (!backTitleVisible || backTitleText === undefined) {
return null;
}

return (
<Text
const title = (
<Animated.Text
accessible={false}
onLayout={this.handleTextLayout}
style={[styles.title, !!tintColor && { color: tintColor }, titleStyle]}
style={[
styles.title,
tintColor ? { color: tintColor } : null,
titleWidth ? { paddingRight: titleWidth } : null,
titleStyle,
]}
numberOfLines={1}
allowFontScaling={!!allowFontScaling}
>
{this.getTitleText()}
</Text>
</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>
);
}

Expand Down Expand Up @@ -173,6 +196,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 17,
letterSpacing: 0.25,
paddingRight: 10,
},
icon: Platform.select({
Expand Down Expand Up @@ -200,6 +224,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;
9 changes: 3 additions & 6 deletions src/components/Header/HeaderTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import * as React from 'react';
import { StyleSheet, Platform } from 'react-native';
import Animated from 'react-native-reanimated';

type Props = {
type Props = React.ComponentProps<typeof Animated.Text> & {
children: string;
style?: React.ComponentProps<typeof Animated.Text>['style'];
};

export default function HeaderTitle({ children, style }: Props) {
return (
<Animated.Text style={[styles.title, style]}>{children}</Animated.Text>
);
export default function HeaderTitle({ style, ...rest }: Props) {
return <Animated.Text {...rest} style={[styles.title, style]} />;
}

const styles = StyleSheet.create({
Expand Down

0 comments on commit b57666d

Please sign in to comment.