Created
November 4, 2019 05:43
-
-
Save thomasgazzoni/ae84e9397ec32f6949a64968dc22a9e2 to your computer and use it in GitHub Desktop.
React Native Header Animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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