|
/* eslint-disable no-use-before-define */ |
|
import React, { PureComponent } from 'react' |
|
import { |
|
View, |
|
ScrollView, |
|
Text, |
|
Animated, |
|
Dimensions, |
|
StyleProp, |
|
TextStyle, |
|
ViewStyle, |
|
ScrollViewProps, |
|
NativeScrollEvent, |
|
NativeSyntheticEvent, |
|
LayoutChangeEvent, |
|
StyleSheet, |
|
TextInput, |
|
SafeAreaView, |
|
TouchableOpacity, |
|
} from 'react-native' |
|
import { ifIphoneX } from 'react-native-iphone-x-helper' |
|
import { FadeDirection, Fade } from 'components/animation/Fade' |
|
import { iOSColors } from 'react-native-typography' |
|
|
|
const { height } = Dimensions.get('window') |
|
const SEARCH_BAR_HEIGHT = 40 |
|
|
|
interface HeaderScrollViewProps { |
|
title?: string |
|
titleStyle?: StyleProp<TextStyle> |
|
headlineStyle?: StyleProp<TextStyle> |
|
children?: React.ReactNode |
|
containerStyle?: StyleProp<ViewStyle> |
|
headerContainerStyle?: StyleProp<ViewStyle> |
|
headerComponentContainerStyle?: StyleProp<ViewStyle> |
|
scrollContainerStyle?: StyleProp<ViewStyle> |
|
fadeDirection?: FadeDirection |
|
scrollViewProps?: ScrollViewProps |
|
showSearchComponent?: boolean |
|
searchBarHeight?: number |
|
} |
|
|
|
export class HeaderScrollView extends PureComponent<HeaderScrollViewProps> { |
|
static defaultProps = { |
|
scrollViewProps: {}, |
|
searchBarHeight: SEARCH_BAR_HEIGHT, |
|
showSearchComponent: true, |
|
ScrollComponent: ScrollView, |
|
} |
|
|
|
state = { |
|
headerHeight: 0, |
|
headerY: 0, |
|
largeTitleHeight: undefined, |
|
isHeaderScrolled: false, |
|
searchBarShrinking: false, |
|
searchBarFixed: false, |
|
} |
|
|
|
scrollAnimatedValue = new Animated.Value(0) |
|
|
|
componentDidMount = () => { |
|
const { showSearchComponent } = this.props |
|
if (showSearchComponent) { |
|
this.scrollAnimatedValue.addListener((value) => { |
|
const { searchBarFixed } = this.state |
|
const { searchBarHeight = SEARCH_BAR_HEIGHT } = this.props |
|
if ( |
|
value.value < -(searchBarHeight + styles.searchContainer.marginTop) |
|
&& !searchBarFixed |
|
) { |
|
this.setState({ |
|
searchBarFixed: true, |
|
searchBarShrinking: false, |
|
}) |
|
} else if (value.value > 0 && searchBarFixed) { |
|
this.setState({ |
|
searchBarFixed: false, |
|
searchBarShrinking: true, |
|
}) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
onLayout = (event: LayoutChangeEvent): void => { |
|
this.setState({ |
|
headerHeight: event.nativeEvent.layout.height, |
|
headerY: event.nativeEvent.layout.y, |
|
}) |
|
} |
|
|
|
onLargeTitleLayout = (event: LayoutChangeEvent) => { |
|
const { largeTitleHeight } = this.state |
|
const { searchBarHeight = 40 } = this.props |
|
if (!largeTitleHeight) { |
|
this.setState({ |
|
largeTitleHeight: event.nativeEvent.layout.height - searchBarHeight + 15, |
|
}) |
|
} |
|
} |
|
|
|
handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
|
const { headerHeight, headerY, isHeaderScrolled: stateIsScrolled } = this.state |
|
const offset = event.nativeEvent.contentOffset.y |
|
const scrollHeaderOffset = headerHeight + headerY - 8 |
|
const isHeaderScrolled = scrollHeaderOffset < offset |
|
|
|
if (!stateIsScrolled && isHeaderScrolled) { |
|
this.setState({ |
|
isHeaderScrolled, |
|
}) |
|
} |
|
|
|
if (stateIsScrolled && !isHeaderScrolled) { |
|
this.setState({ |
|
isHeaderScrolled, |
|
}) |
|
} |
|
} |
|
|
|
render() { |
|
const { |
|
children, |
|
title = '', |
|
titleStyle, |
|
containerStyle = {}, |
|
headerContainerStyle = {}, |
|
headerComponentContainerStyle = {}, |
|
headlineStyle = {}, |
|
scrollContainerStyle = {}, |
|
fadeDirection, |
|
scrollViewProps = {}, |
|
searchBarHeight = SEARCH_BAR_HEIGHT, |
|
showSearchComponent, |
|
} = this.props |
|
const { |
|
isHeaderScrolled, |
|
searchBarFixed, |
|
searchBarShrinking, |
|
largeTitleHeight: _largeTitleHeight, |
|
} = this.state |
|
|
|
const fontSize = titleStyle ? (titleStyle as any).fontSize : 34 |
|
const titleStyles = { |
|
fontSize, |
|
lineHeight: fontSize * 1.2, |
|
} |
|
const estimatedLargeTitleHeight = titleStyles.lineHeight + ifIphoneX(44, 0) |
|
const largeTitleHeight = _largeTitleHeight || estimatedLargeTitleHeight |
|
const expandedLargeTitleHeight = largeTitleHeight |
|
+ searchBarHeight |
|
+ styles.searchContainer.marginTop |
|
|
|
const animatedFontSize = this.scrollAnimatedValue.interpolate({ |
|
inputRange: [-height, 100], |
|
outputRange: [fontSize * 1.75, fontSize], |
|
extrapolate: 'clamp', |
|
}) |
|
|
|
const animatedSearchScale = this.scrollAnimatedValue.interpolate({ |
|
inputRange: [-searchBarHeight, 0], |
|
outputRange: [searchBarHeight, 0], |
|
extrapolate: 'clamp', |
|
}) |
|
|
|
const animatedSearchScaleReverse = this.scrollAnimatedValue.interpolate({ |
|
inputRange: [0, searchBarHeight], |
|
outputRange: [searchBarHeight, 0], |
|
extrapolate: 'clamp', |
|
}) |
|
|
|
const animationLargeTitleHeight = this.scrollAnimatedValue.interpolate({ |
|
inputRange: [-searchBarHeight, 0], |
|
outputRange: [expandedLargeTitleHeight, largeTitleHeight], |
|
extrapolate: 'clamp', |
|
}) |
|
|
|
const animationLargeTitleHeightShrink = this.scrollAnimatedValue.interpolate({ |
|
inputRange: [0, searchBarHeight], |
|
outputRange: [expandedLargeTitleHeight, largeTitleHeight], |
|
extrapolate: 'clamp', |
|
}) |
|
|
|
const largeTitleAnimation = searchBarShrinking |
|
? animationLargeTitleHeightShrink |
|
: animationLargeTitleHeight |
|
|
|
const searchFieldAnimation = searchBarShrinking |
|
? animatedSearchScaleReverse |
|
: animatedSearchScale |
|
|
|
return ( |
|
<View style={[styles.container, containerStyle]}> |
|
<View style={[styles.headerContainer, headerContainerStyle]}> |
|
<SafeAreaView style={[styles.headerComponentContainer, headerComponentContainerStyle]}> |
|
<View style={styles.headerComponentLeft}> |
|
<TouchableOpacity> |
|
<Text style={styles.defaultButton}>Edit</Text> |
|
</TouchableOpacity> |
|
</View> |
|
<Fade |
|
style={styles.headerComponentMain} |
|
visible={isHeaderScrolled} |
|
direction={fadeDirection} |
|
> |
|
<Text style={[styles.headline, headlineStyle]}>{title}</Text> |
|
</Fade> |
|
<View /> |
|
</SafeAreaView> |
|
</View> |
|
<ScrollView |
|
onScroll={Animated.event( |
|
[ |
|
{ |
|
nativeEvent: { contentOffset: { y: this.scrollAnimatedValue } }, |
|
}, |
|
], |
|
{ |
|
listener: this.handleScroll, |
|
}, |
|
)} |
|
scrollEventThrottle={8} |
|
contentContainerStyle={scrollContainerStyle} |
|
{...scrollViewProps} |
|
> |
|
<React.Fragment> |
|
<Animated.View |
|
style={[ |
|
styles.largeHeader, |
|
showSearchComponent |
|
? { |
|
height: searchBarFixed ? expandedLargeTitleHeight : largeTitleAnimation, |
|
} |
|
: { |
|
paddingBottom: 15, |
|
}, |
|
]} |
|
onLayout={this.onLargeTitleLayout} |
|
> |
|
<View style={styles.scroll}> |
|
<Animated.Text |
|
style={[ |
|
styles.title, |
|
titleStyle, |
|
titleStyles, |
|
{ |
|
fontSize: animatedFontSize, |
|
}, |
|
]} |
|
onLayout={this.onLayout} |
|
> |
|
{title} |
|
</Animated.Text> |
|
</View> |
|
{showSearchComponent ? ( |
|
<Animated.View |
|
style={[ |
|
styles.searchContainer, |
|
{ height: searchBarFixed ? searchBarHeight : searchFieldAnimation }, |
|
]} |
|
> |
|
<Fade visible={searchBarFixed}> |
|
<TextInput placeholder="Search" /> |
|
</Fade> |
|
</Animated.View> |
|
) : null} |
|
</Animated.View> |
|
<Animated.View style={styles.children}>{children}</Animated.View> |
|
</React.Fragment> |
|
</ScrollView> |
|
</View> |
|
) |
|
} |
|
} |
|
|
|
const containerHeight = ifIphoneX(88, 60) |
|
|
|
const styles = StyleSheet.create({ |
|
container: { flex: 1, backgroundColor: 'transparent' }, |
|
headerContainer: { |
|
height: containerHeight, |
|
}, |
|
largeHeader: { |
|
borderBottomColor: 'rgba(0, 0, 0, 0.2)', |
|
borderBottomWidth: 1, |
|
backgroundColor: 'white', |
|
}, |
|
headerComponentContainer: { |
|
height: containerHeight, |
|
alignItems: 'center', |
|
justifyContent: 'space-between', |
|
flexDirection: 'row', |
|
paddingBottom: 12, |
|
marginLeft: 20, |
|
marginRight: 20, |
|
}, |
|
headerComponentLeft: { |
|
justifyContent: 'center', |
|
alignItems: 'flex-start', |
|
}, |
|
headerComponentMain: { |
|
justifyContent: 'center', |
|
alignItems: 'center', |
|
}, |
|
headerComponentRight: { |
|
justifyContent: 'center', |
|
alignItems: 'flex-end', |
|
}, |
|
headline: { |
|
fontSize: 17, |
|
lineHeight: 22, |
|
fontWeight: '500', |
|
letterSpacing: 0.019, |
|
}, |
|
title: { |
|
letterSpacing: 0.011, |
|
fontWeight: '700', |
|
}, |
|
scroll: { |
|
marginLeft: 20, |
|
marginRight: 20, |
|
}, |
|
searchContainer: { |
|
marginTop: 15, |
|
marginLeft: 20, |
|
marginRight: 20, |
|
backgroundColor: iOSColors.lightGray, |
|
height: SEARCH_BAR_HEIGHT, |
|
left: 0, |
|
right: 0, |
|
borderRadius: 5, |
|
justifyContent: 'center', |
|
alignItems: 'flex-start', |
|
paddingLeft: 15, |
|
paddingRight: 15, |
|
}, |
|
search: { |
|
padding: 10, |
|
opacity: 0, |
|
}, |
|
children: { |
|
flex: 1, |
|
}, |
|
defaultButton: { |
|
color: iOSColors.blue, |
|
}, |
|
}) |
|
|
|
export default HeaderScrollView |
Hey it’s exactly what I was looking for! How can I use it? I copy this in a file in my project and then wrap a screen with the component HeaderScrollView? What props has the component?