Skip to content

Instantly share code, notes, and snippets.

@thomasgazzoni
Created November 4, 2019 05:43
Show Gist options
  • Save thomasgazzoni/ae84e9397ec32f6949a64968dc22a9e2 to your computer and use it in GitHub Desktop.
Save thomasgazzoni/ae84e9397ec32f6949a64968dc22a9e2 to your computer and use it in GitHub Desktop.
React Native Header Animation
import React, { createRef, PureComponent, ReactNode } from 'react';
import {
Animated,
KeyboardAvoidingView,
Platform,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import { S, V } from '../themes';
import Header from './Header';
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: V.contentBgColor,
},
});
interface IProps {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
/**
* set keyboardAvoidingView if in the page we need to use the ViewFooterButton and
* we want the button stick on the bottom when the keyboard show up
*/
keyboardAvoidingView?: boolean;
onContainerClick?: () => void;
}
export default class Container extends PureComponent<IProps> {
static defaultProps = {
children: undefined,
style: undefined,
keyboardAvoidingView: false,
onContainerClick: undefined,
};
headerRef = createRef<Header>();
headerScrollNotified = false;
setHeaderScrollPosition = (scrollY: Animated.Value) => {
if (this.headerRef.current && !this.headerScrollNotified) {
this.headerRef.current.setHeaderScrollPosition(scrollY);
this.headerScrollNotified = true;
}
};
render() {
const {
style,
keyboardAvoidingView,
onContainerClick,
children,
} = this.props;
const composeStyle = [styles.container, style];
const content = React.Children.map(children, child => {
if (!React.isValidElement(child)) {
return false;
}
if (child.type.displayName === 'Header') {
return React.cloneElement(child, { ref: this.headerRef });
}
if (child.type.displayName === 'Content') {
return React.cloneElement(child, {
...child.props,
onScroll: this.setHeaderScrollPosition,
});
}
return child;
});
if (keyboardAvoidingView && Platform.OS === 'ios') {
return (
<KeyboardAvoidingView
style={S.flex}
behavior="padding"
keyboardVerticalOffset={V.iOSStatusBarHeight}
>
<View style={composeStyle}>{content}</View>
</KeyboardAvoidingView>
);
}
return (
<View style={composeStyle} onTouchEnd={onContainerClick}>
{content}
</View>
);
}
}
import React, { PureComponent, ReactElement, ReactNode } from 'react';
import {
Animated,
Image,
ImageSourcePropType,
KeyboardAvoidingView,
Platform,
RefreshControlProps,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import { V } from '../themes';
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'column',
backgroundColor: V.defaultBgColor,
},
backgroundImage: {
position: 'absolute',
resizeMode: 'cover',
top: 0,
height: '100%',
width: '100%',
},
});
type Props = {
/**
* if true, use ScrollView otherwise use View
*/
scrollable: boolean;
/**
* Apply bounces when reach end of scrolling
*/
bounces: boolean;
/**
* Hide content
*/
hidden: boolean;
/**
* Apply RN keyboardAvoidingView wrapper to the view
*/
keyboardAvoidingView: boolean;
/**
* Behavior of the Keyboard when click outside
*/
keyboardShouldPersistTaps: 'never' | 'always' | 'handled';
backgroundImage: ImageSourcePropType;
/**
* Needed to compensate the header height (Default: V.headerHeightBig)
*/
headerHeight: number;
children?: ReactNode;
style?: StyleProp<ViewStyle>;
refreshControl?: ReactElement<RefreshControlProps>;
scrollEnabled?: boolean;
onScroll?: (scrollY: Animated.Value) => void;
onScrollCall?: (scrollY: Animated.Value) => void;
};
export default class Content extends PureComponent<Props> {
static defaultProps = {
scrollable: false,
bounces: false,
hidden: false,
keyboardAvoidingView: false,
keyboardShouldPersistTaps: 'never',
transparent: false,
backgroundImage: undefined,
headerHeight: V.headerHeightBig,
children: undefined,
style: undefined,
onScroll: undefined,
onScrollCall: undefined,
refreshControl: undefined,
scrollEnabled: true,
};
static displayName = 'Content';
scrollView = undefined;
scrollY = new Animated.Value(0);
onScrollInternal = undefined;
componentWillUnmount() {
this.scrollY.removeAllListeners();
}
updateScrollPosition = () => {
const { onScroll } = this.props;
if (onScroll) {
onScroll(this.scrollY);
}
if (this.onScrollInternal) {
this.onScrollInternal(this.scrollY);
}
};
render() {
const {
scrollable,
bounces,
hidden,
keyboardAvoidingView,
keyboardShouldPersistTaps,
backgroundImage,
headerHeight,
children,
style,
refreshControl,
scrollEnabled,
} = this.props;
if (hidden) {
return false;
}
const composedStyles = [styles.container, style];
let backgroundImageCmp;
if (backgroundImage) {
backgroundImageCmp = (
<Image source={backgroundImage} style={styles.backgroundImage} />
);
}
let hasAnimatedFlatList = false;
const parsedChildren = React.Children.map(children, child => {
if (
child &&
child.type &&
child.type.displayName === 'FlatListAnimated'
) {
hasAnimatedFlatList = true;
return React.cloneElement(child, {
...child.props,
scrollEventThrottle: 1,
progressViewOffset: headerHeight,
scrollIndicatorInsets: {
top: headerHeight,
},
contentContainerStyle: [
{ paddingTop: headerHeight },
child.props.contentContainerStyle || {},
],
onScroll: Animated.event(
[{ nativeEvent: { contentOffset: { y: this.scrollY } } }],
{
useNativeDriver: true,
listener: this.updateScrollPosition,
}
),
});
}
return child;
});
let contentCmp: ReactNode = false;
if (scrollable) {
contentCmp = (
<Animated.ScrollView
scrollEnabled={scrollEnabled}
style={composedStyles}
contentContainerStyle={[{ paddingTop: headerHeight }]}
scrollIndicatorInsets={{
top: headerHeight,
}}
bounces={bounces}
scrollEventThrottle={1}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.scrollY } } }],
{
useNativeDriver: true,
listener: this.updateScrollPosition,
}
)}
refreshControl={refreshControl}
>
{backgroundImageCmp}
{children}
</Animated.ScrollView>
);
} else {
contentCmp = (
<View style={composedStyles}>
{backgroundImageCmp}
{!hasAnimatedFlatList && <View style={{ height: headerHeight }} />}
{parsedChildren}
</View>
);
}
if (keyboardAvoidingView && Platform.OS === 'ios') {
return (
<KeyboardAvoidingView style={composedStyles} behavior="padding" enabled>
{contentCmp}
</KeyboardAvoidingView>
);
}
return contentCmp;
}
}
import React, { Component, ReactNode } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { goBack } from '../navigator';
import { C, IMAGES, S, V } from '../themes';
import HeaderTitle from './Header/HeaderTitle';
import Image from './Image';
const HEADER_MIN_HEIGHT = V.headerHeight;
const HEADER_MAX_HEIGHT = V.headerHeightBig;
const HEADER_RIGHT_WIDTH = 60;
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
flex: 0,
zIndex: 1,
width: V.pgWidth,
},
header: {
position: 'absolute',
top: 0,
flex: 0,
zIndex: 1,
width: V.pgWidth,
flexDirection: 'column',
alignItems: 'flex-start',
},
headerTop: {
height: V.headerHeight,
alignItems: 'center',
flexDirection: 'row',
},
headerBottom: {
height: V.headerHeight,
width: V.pgWidth,
alignItems: 'center',
flexDirection: 'row',
paddingHorizontal: V.basePadding,
},
headerFilters: {
minHeight: V.headerHeight,
width: V.pgWidth,
flexDirection: 'column',
flex: 1,
},
// buttons & title
headerLeftActions: {
position: 'absolute',
top: 0,
left: 0,
alignItems: 'center',
justifyContent: 'flex-start',
flexDirection: 'row',
height: V.headerHeight,
zIndex: 2,
},
headerRightActions: {
position: 'absolute',
top: 0,
right: 0,
alignItems: 'center',
justifyContent: 'flex-end',
flexDirection: 'row',
height: V.headerHeight,
zIndex: 2,
},
});
type Props = {
title?: string;
hideBackButton?: boolean;
backButtonIcon?: number;
hideHeaderBottom?: boolean;
headerTop?: ReactNode;
headerBottom?: ReactNode;
headerLeft?: ReactNode;
headerRightText?: string;
headerRightIcon?: ReactNode;
headerFilters?: ReactNode;
headerActions?: ReactNode;
pointerEvents?: 'none' | 'auto';
backgroundColor?: string;
onLeftClick?: () => void;
onRightClick?: () => void;
};
type State = {
scrollY: Animated.Value;
};
export default class Header extends Component<Props, State> {
static defaultProps = {
title: '',
hideBackButton: false,
backButtonIcon: IMAGES.icon_arrow_back,
hideHeaderBottom: false,
headerTop: undefined,
headerBottom: undefined,
headerLeft: undefined,
headerRightText: undefined,
headerRightIcon: undefined,
headerFilters: undefined,
headerActions: undefined,
pointerEvents: 'none',
backgroundColor: V.headerBgColor,
onLeftClick: undefined,
onRightClick: undefined,
};
static displayName = 'Header';
constructor(props: Props) {
super(props);
this.state = {
scrollY: new Animated.Value(0),
};
}
doGoBack = () => {
const { onLeftClick } = this.props;
if (onLeftClick) {
onLeftClick();
} else {
goBack();
}
};
setHeaderScrollPosition = (scrollY: Animated.Value<number>) => {
this.setState({ scrollY });
};
renderBackButton = () => {
const { hideBackButton, backButtonIcon, headerLeft } = this.props;
if (hideBackButton) {
return false;
}
return (
<View style={styles.headerLeftActions}>
{headerLeft || (
<TouchableOpacity style={S.paddingBase} onPress={this.doGoBack}>
<Image source={backButtonIcon} size={22} />
</TouchableOpacity>
)}
</View>
);
};
renderHeaderActions = () => {
const {
headerActions,
headerRightIcon,
headerRightText,
onRightClick,
} = this.props;
let headerActionBtn: ReactNode = false;
if (headerRightIcon || headerRightText) {
headerActionBtn = (
<TouchableOpacity style={S.paddingBase} onPress={onRightClick}>
{headerRightIcon ? (
<Image source={headerRightIcon} width={22} />
) : (
<Text
style={[
C.textNormalPrimary,
{ width: HEADER_RIGHT_WIDTH },
S.textCenter,
]}
>
{headerRightText}
</Text>
)}
</TouchableOpacity>
);
}
if (headerActionBtn || headerActions) {
return (
<View style={styles.headerRightActions}>
{headerActions}
{headerActionBtn}
</View>
);
}
return false;
};
render() {
const {
title,
headerTop,
headerBottom,
headerFilters,
hideHeaderBottom,
backgroundColor,
pointerEvents,
headerRightText,
} = this.props;
const { scrollY } = this.state;
const headerHeight = hideHeaderBottom
? HEADER_MIN_HEIGHT
: HEADER_MAX_HEIGHT;
const animationRange = scrollY.interpolate({
inputRange: [0, headerHeight],
outputRange: [0, 1],
extrapolate: 'clamp',
});
const animateY = {
transform: [
{
translateY: animationRange.interpolate({
inputRange: [0, 1],
outputRange: [0, -Math.round(headerHeight / 2)],
extrapolate: 'clamp',
}),
},
],
};
const HeaderTopCmp = <View style={styles.headerTop}>{headerTop}</View>;
const HeaderBottomCmp = !hideHeaderBottom && (
<View style={styles.headerBottom}>
{headerBottom || (
<HeaderTitle
style={
headerRightText ? { marginRight: HEADER_RIGHT_WIDTH } : false
}
animationRange={animationRange}
title={title}
/>
)}
</View>
);
const HeaderFiltersCmp = !!headerFilters && (
<View style={styles.headerFilters}>
{React.Children.map(headerFilters, child =>
React.cloneElement(child, {
...child.props,
scrollY,
})
)}
</View>
);
return (
<React.Fragment>
{this.renderBackButton()}
{this.renderHeaderActions()}
<Animated.View
pointerEvents={pointerEvents}
style={[
styles.header,
{ backgroundColor },
!hideHeaderBottom ? animateY : false,
]}
>
{HeaderTopCmp}
{HeaderBottomCmp}
{HeaderFiltersCmp}
</Animated.View>
</React.Fragment>
);
}
}
import React, { Component } from 'react';
import {
Animated,
LayoutChangeEvent,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import { V } from '../../themes';
const styles = StyleSheet.create({
headerTitleView: {
height: V.headerHeight,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flex: 1,
},
headerTitleText: {
textAlign: 'left',
fontSize: V.headerFontSize,
color: V.headerColor,
fontWeight: 'bold',
},
});
interface IProps {
/**
* Header (page) title
*/
title: string;
/**
* If given the Title will be animated
*/
animationRange?: Animated.AnimatedInterpolation;
style: StyleProp<ViewStyle>;
}
interface IState {
containerX: number;
textWidth: number;
}
export default class HeaderTitle extends Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
containerX: 0,
textWidth: 0,
};
}
render() {
const { animationRange, title, style } = this.props;
const { containerX, textWidth } = this.state;
const scaleTo = 0.8;
const backBtnWidth = 48;
const complementScaling = 1 - scaleTo;
const horizontalScalingDiff = (complementScaling * textWidth) / 2;
const animateScale = animationRange && {
scale: animationRange.interpolate({
inputRange: [0, 1],
outputRange: [1, scaleTo],
}),
};
const animateX = animationRange && {
translateX: animationRange.interpolate({
inputRange: [0, 1],
outputRange: [0, backBtnWidth - containerX - horizontalScalingDiff],
}),
};
const animateOpacity = animationRange && {
opacity: animationRange.interpolate({
inputRange: [0, 0.9, 1],
outputRange: [1, 0.2, 1],
}),
};
const onContainerSetMeasurements = ({
nativeEvent: { layout },
}: LayoutChangeEvent) => {
this.setState({ containerX: layout.x });
};
const onTextSetMeasurements = ({
nativeEvent: { layout },
}: LayoutChangeEvent) => {
this.setState({ textWidth: layout.width });
};
return (
<View
style={[styles.headerTitleView, style]}
onLayout={onContainerSetMeasurements}
>
<Animated.Text
style={[
styles.headerTitleText,
animateX && {
transform: [animateScale, animateX],
},
animateOpacity,
]}
numberOfLines={1}
onLayout={onTextSetMeasurements}
>
{title}
</Animated.Text>
</View>
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment