From b57666d8cbebc40b80dc827b339a4c63fce85097 Mon Sep 17 00:00:00 2001 From: "satyajit.happy" Date: Tue, 21 May 2019 18:00:45 +0200 Subject: [PATCH] Initial work on UIKit style animation for header --- src/components/Header/HeaderAnimated.tsx | 78 ++++++++++++++--- src/components/Header/HeaderAnimatedItem.tsx | 44 ++++++++-- src/components/Header/HeaderBackButton.tsx | 92 ++++++++++++++------ src/components/Header/HeaderTitle.tsx | 9 +- 4 files changed, 172 insertions(+), 51 deletions(-) diff --git a/src/components/Header/HeaderAnimated.tsx b/src/components/Header/HeaderAnimated.tsx index ebc2731..f9321fd 100644 --- a/src/components/Header/HeaderAnimated.tsx +++ b/src/components/Header/HeaderAnimated.tsx @@ -29,23 +29,75 @@ type Props = { style?: StyleProp; }; -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 }, }; }, }; @@ -54,7 +106,7 @@ export default class HeaderAnimated extends React.Component< Props > { static defaultProps = { - preset: FadePreset, + preset: Platform.OS === 'ios' ? UIKitPreset : FadePreset, }; render() { diff --git a/src/components/Header/HeaderAnimatedItem.tsx b/src/components/Header/HeaderAnimatedItem.tsx index 3dfe7f8..4f8d70c 100644 --- a/src/components/Header/HeaderAnimatedItem.tsx +++ b/src/components/Header/HeaderAnimatedItem.tsx @@ -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'; @@ -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 = { @@ -39,9 +46,15 @@ type Props = { style?: StyleProp; }; +type State = { + titleWidth?: number; +}; + export default class HeaderAnimatedItem< T extends Route -> extends React.Component> { +> extends React.Component, State> { + state: State = {}; + private getInterpolatedStyle = memoize( ( styleInterpolator: StyleInterpolator, @@ -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, @@ -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, @@ -73,10 +95,18 @@ export default class HeaderAnimatedItem< {previous ? ( - + ) : null} - + {scene.title} diff --git a/src/components/Header/HeaderBackButton.tsx b/src/components/Header/HeaderBackButton.tsx index c7a6cce..e951a5e 100644 --- a/src/components/Header/HeaderBackButton.tsx +++ b/src/components/Header/HeaderBackButton.tsx @@ -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; + titleStyle?: React.ComponentProps['style']; width?: number; }; type State = { - initialTextWidth?: number; + initialTitleWidth?: number; }; class HeaderBackButton extends React.Component { @@ -48,11 +44,12 @@ class HeaderBackButton extends React.Component { 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, }); }; @@ -62,9 +59,7 @@ class HeaderBackButton extends React.Component { let title = this.getTitleText(); if (backImage) { - const BackImage = backImage; - - return ; + return backImage({ tintColor, title }); } else { return ( { 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) { @@ -100,25 +95,53 @@ class HeaderBackButton extends React.Component { 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 ( - {this.getTitleText()} - + + ); + + if (backImage) { + return title; + } + + return ( + + + + + } + > + {title} + ); } @@ -173,6 +196,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 17, + letterSpacing: 0.25, paddingRight: 10, }, icon: Platform.select({ @@ -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; diff --git a/src/components/Header/HeaderTitle.tsx b/src/components/Header/HeaderTitle.tsx index a542b3f..01ac553 100644 --- a/src/components/Header/HeaderTitle.tsx +++ b/src/components/Header/HeaderTitle.tsx @@ -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 & { children: string; - style?: React.ComponentProps['style']; }; -export default function HeaderTitle({ children, style }: Props) { - return ( - {children} - ); +export default function HeaderTitle({ style, ...rest }: Props) { + return ; } const styles = StyleSheet.create({