Skip to content

Instantly share code, notes, and snippets.

@jesster2k10
Created July 4, 2019 20:52
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jesster2k10/519d0fbc584f9d226f0813b1864dac8a to your computer and use it in GitHub Desktop.
Save jesster2k10/519d0fbc584f9d226f0813b1864dac8a to your computer and use it in GitHub Desktop.
React Native iOS 11 Style Large Title with Support For Search Component & Menu Buttons

This gist is based on the amazing library react-native-header-scroll-view ( https://github.com/jonsamp/react-native-header-scroll-view )

What I did was extend the base of that library to make it fit the needs of my own project. Which was:

  • Adding TypeScript Support
  • Adding the option for a search component
  • Animation the search component shrink/grow on scroll
  • Adding header buttons
  • Work with React Navigation

The code has yet to be tested so use at your own risk.

The only two dependcies are

  • react-native-iphone-x-helper
  • react-native-typography

Which could be easily swapped out.

The code has been quite modified but all credits do go to the original author in terms of figuring out the main logic. There are a couple issues with it right now but it works pretty good (and close to the original)

I might turn this into a proper library in the future but for now I think this gist is sufficent :)

import React, { Component } from 'react'
import { Animated, StyleProp, ViewStyle } from 'react-native'
export type FadeDirection = 'up' | 'down'
interface FadeProps {
visible?: boolean
style?: StyleProp<ViewStyle>
children: React.ReactNode
direction?: FadeDirection
}
interface FadeState {
visible?: boolean
}
export class Fade extends Component<FadeProps, FadeState> {
static defaultProps = {
direction: 'up',
visible: true,
}
private visibility: Animated.Value = new Animated.Value(0)
constructor(props: FadeProps) {
super(props)
this.state = {
visible: props.visible || true,
}
}
componentWillReceiveProps({ visible }: FadeProps) {
Animated.timing(this.visibility, {
toValue: visible ? 1 : 0,
duration: 200,
}).start(() => !visible && this.setState({ visible }))
if (visible) this.setState({ visible })
}
render(): JSX.Element {
// prettier-ignore
const {
style,
children,
direction = 'down',
...rest
} = this.props
const { visible } = this.state
const directions = {
up: [5, 0],
down: [-5, 0],
}
const test = this.visibility.interpolate({
inputRange: [0, 1],
outputRange: directions[direction] || [0, 0],
})
const containerStyle = {
opacity: this.visibility.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
transform: [
{
translateY: test,
},
],
}
const combinedStyle = [containerStyle, style]
return (
<Animated.View style={visible ? combinedStyle : containerStyle} {...rest}>
{visible ? children : null}
</Animated.View>
)
}
}
/* 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
@joamartico
Copy link

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?

@joamartico
Copy link

Also, it works with expo-web right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment