Implementing Collapsing Toolbar Using React Native
Collapsing toolbar is really cool, both visually and in user experience point of view. We can start with initial view with huge header, giving the user a bit of context, then collapsing it when the user start scrolling the content, giving her the screen real estate to enjoy the page’s content. In this article, I will explore how we can achieve that using React Native.
View Structure
Let’s start with a very simple view structure. We will have a header that we will collapse and a ScrollView
that will contain our long content.
import React, { Component } from 'react';
...
import content from './content';export default class App extends Component {
render(){
return(
<View style={styles.container}>
<View style={{height: 300}}/>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<Text style={styles.title}>This is Title</Text>
<Text style={styles.content}>{content}</Text>
</ScrollView>
</View>
)
}
}const styles = StyleSheet.create({
container: {
flex: 1
},
scrollContainer: {
padding: 16
},
title: {
fontSize: 24,
marginVertical: 16
}
})
Our first attempt doesn’t look so good as the header stays where it is. So, we will make the height changes as we scroll the content up using RN’s Animated
library.
Animated and onScroll Combination
The Animated
library provides several useful APIs to create an animation, but we will focus on the event
and interpolate
method. The event
method can map scrolling events into animated values. We will then calculate the header’s height based on these values using the interpolate
method.
...
const HEADER_EXPANDED_HEIGHT = 300
const HEADER_COLLAPSED_HEIGHT = 60export default class App extends Component {
constructor() {
super()
this.state = {
scrollY: new Animated.Value(0)
}
} render() {
const headerHeight = this.state.scrollY.interpolate({
inputRange: [0, HEADER_EXPANDED_HEIGHT-HEADER_COLLAPSED_HEIGHT],
outputRange: [HEADER_EXPANDED_HEIGHT, HEADER_COLLAPSED_HEIGHT],
extrapolate: 'clamp'
}) return(
<View style={styles.container}>
<Animated.View style={{height: headerHeight}}/>
<ScrollView
contentContainerStyle={styles.scrollContainer}
onScroll={Animated.event(
[{ nativeEvent: {
contentOffset: {
y: this.state.scrollY
}
}
}])}
scrollEventThrottle={16}>
...
</ScrollView>
</View>
)
}
}
We subscribe Animated.event
to the onScroll
event of our ScrollView
component. This will assign a new value to the scrollY
state every time the scrolling event fired, thus invoking a re-render.
The scrollY
value will be mapped into headerHeight
value using the interpolate
method. In the above example, we are only interested in the scrolling event in the area between the expanded and the collapsed header height. Thus, scrolling beyond those area would not affect our header.
Now, we have a collapsing header. However, it’s a little bit off since the content scroll right away without waiting the header collapse completely. In order to fix it, we will set the header’s position as absolute and set a top padding of the ScrollView
equal to height of the expanded header.
const { width: SCREEN_WIDTH } = Dimensions.get('screen')
...
return(
<View style={styles.container}>
<Animated.View style={{height: headerHeight, width: SCREEN_WIDTH, position: 'absolute', top: 0, left: 0}}/>
<ScrollView
contentContainerStyle={{padding: 16, paddingTop: HEADER_EXPANDED_HEIGHT}}
...>
...
</ScrollView>
</View>)
...
Using the same basic principle of animating the header, we could animate other things inside the header.
...
render() {
...
const headerTitleOpacity = this.state.scrollY.interpolate({
inputRange: [0, HEADER_EXPANDED_HEIGHT-HEADER_COLLAPSED_HEIGHT],
outputRange: [0, 1],
extrapolate: 'clamp'
}); const heroTitleOpacity = this.state.scrollY.interpolate({
inputRange: [0, HEADER_EXPANDED_HEIGHT-HEADER_COLLAPSED_HEIGHT],
outputRange: [1, 0],
extrapolate: 'clamp'
});return (
<View style={styles.container}>
<Animated.View style={[styles.header, { height: headerHeight }]}>
<Animated.Text style={{textAlign: 'center', marginTop: 28, opacity: headerTitleOpacity}}>{headerTitle}</Animated.Text>
<Animated.Text style={{position: 'absolute', bottom: 16, left: 16, opacity: heroTitleOpacity}}>{headerTitle}</Animated.Text>
</Animated.View>
...
</View>
)
}
You can access the full code here.