BottomSheet with ReactNative and PanResponder. Receipt of duck soup!
Prologue.
I know that animation on ReactNative scares a lot of developers, but that is not so scary after all. A few simple functions can help you create BottomSheet without any issues.
So, here’s what we are going to do:
Episode 1. The begining.
Start a new project :)
Use react-native init BottomSheet
to generate a project from scratch.
Create BottomSheet
folder under src
folder with index.js
, styles.js
, pan-responder.js
, constants.js
files, just for better decomposition.
Our file structure:
| - /BottomSheet
| — - /src
| — — - /BottomSheet
| - — — - /index.js
| - — — - /constants.js
| - — — - /pan-responder.js
| - — — - /styles.js
Episode 2. Mark-up.
There is no difference if we are going to make it with a stateful or stateless component since we will totally avoid re-rendering of our component. Please, do not use the component state
to keep Animated.Value
unless you’re going to replace the value with a new Animated.Value
object.
Our index.js
file:
import React from 'react'
import { Animated, View, SafeAreaView, Text } from 'react-native'
import { animatedPosition, panGesture } from './pan-responder'
import styles from './styles'
function BottomSheet () {
return (
<Animated.View style={[styles.container, { bottom: animatedPosition }]}>
<View style={styles.gestureArea} {...panGesture.panHandlers}>
<View style={styles.pullItem} />
</View>
<SafeAreaView style={styles.content}>
<View style={styles.container}>
<Text style={styles.text}>Your awesome content</Text>
</View>
</SafeAreaView>
</Animated.View>
)
}
export default BottomSheet
Let’s walk through the code above.
- Imports:
*Animated
is a basic wrapper for animation in ReactNative. It can often be used to animate appearance for example. But in this case, we just animate UI to respond to our gestures.
*SafeAreaView
have two usages here. First, I use this as a container. Second, avoid content overlapping with system multi-tasking control. - Mark-up:
*<Animated.View />
basic way to make animation in ReactNative.
Very important! Changes of inanimatedPosition
variable do NOT call re-render (we are creating a new instance of thenew Animated.Value()
and manipulating value inside the instance).
* Nested<View {...panGesture.panHandlers} />
. This is the main place, where magic is happening. Just remember this construction, I will describe everything in Episode 4.
Episode 3. Styling.
In my code, I always try to follow the SST rule (Single Source of Truth). In this tutorial, I follow it too, to control sizes and position of the BottomSheet.
So, let’s start with our BottomSheet config through the constants, in our constants.js
file:
export const ANIMATED = {
HIDDEN: -350,
FULL_OPEN: -100,
VISIBLE: -300
}
HIDDEN
— is equal to the component height, the only difference it should be positive.
FULL_OPEN
— visible part of the component will be only 250
when BottomSheet is fully open. We need this to do our component a little bit larger than the screen.
VISIBLE
— this value is required to calculate the visible part of the BottomSheet when it’s closed.
Notice, if you want to manipulate sheet size, you can adjust these values as you want and they will be applied to styles
and to animation handlers.
Our styles.js
file:
import { StyleSheet } from 'react-native'
import { COLORS } from '../../styles'
import { DEVICE } from '../../constants'
import { ANIMATED } from './constants'
export default StyleSheet.create({
container: {
position: 'absolute',
left: 0,
width: DEVICE.width,
height: Math.abs(ANIMATED.HIDDEN),
marginBottom: ANIMATED.HIDDEN - ANIMATED.VISIBLE,
paddingBottom: Math.abs(ANIMATED.FULL_OPEN),
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
borderWidth: 1,
borderColor: COLORS.SECONDARY,
backgroundColor: COLORS.WHITE,
shadowColor: COLORS.BLACK,
shadowOffset: {
width: 0,
height: 3
},
shadowOpacity: 0.27,
shadowRadius: 4.65,
elevation: 6
},
gestureArea: {
width: DEVICE.width,
height: 40,
marginTop: -10,
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
},
pullItem: {
width: 40,
height: 5,
borderRadius: 20,
backgroundColor: COLORS.SECONDARY
},
content: {
marginVertical: 30,
paddingHorizontal: 10,
height: '100%',
}
})
Here you can find a little Math operation (eg. Math.abs
is converting a negative number to a positive).
Let’s walk through the code:
marginBottom: ANIMATED.HIDDEN — ANIMATED.VISIBLE
this value is equal to50
is done to make our BottomSheet partly visible for our default state.paddingBottom: Math.abs(ANIMATED.FULL_OPEN)
is equal to100
. The reason for this is to make padding for our behind-the-screen content that will be invisible.- About
DEVICE
variable is just a regular ReactNative way to get device dimensions (Dimensions.get()
), alternative to this solution is to work with percentage (eg. you can use100%)
. COLORS
variable is a way to use the SST rule and set colour in one place.
The red area is totally touchable. It’s done to do more user-friendly behaviour. Responsible for its styling — gestureArea
in our styles.js
.
Green area is where our content goes. You can see on the bottom (white area) — this is SafeAreaView
help us to avoid the system draggable zone. Styles that are responsible for this are content
in our styles.js
file.
Episode 4. The magic.
Let’s start with our code in pan-responder.js
:
import { Animated, PanResponder } from 'react-native'
import { DEVICE } from '../../constants'
import { ANIMATED } from './constants'
const STARTING_POSITION = ANIMATED.HIDDEN - ANIMATED.FULL_OPEN
const animatedPosition = new Animated.Value(STARTING_POSITION)
function animateMove (toValue) {
Animated.spring(animatedPosition, {
toValue,
tension: 120
}).start()
}
function movementValue (gestureState) {
return DEVICE.height - gestureState.moveY + ANIMATED.VISIBLE
}
function onMoveShouldSetPanResponder (_, gestureState) {
return gestureState.dy >= 10 || gestureState.dy <= -10
}
function onPanResponderMove (_, gestureState) {
const toValue = Math.min(0, movementValue(gestureState))
animateMove(toValue)
}
function onPanResponderRelease (_, gestureState) {
const isMovedMoreThenThird = movementValue(gestureState) < ANIMATED.HIDDEN / 3
const toValue = isMovedMoreThenThird ? STARTING_POSITION : 0
animateMove(toValue)
}
const panGesture = PanResponder.create({
onPanResponderMove,
onPanResponderRelease,
onMoveShouldSetPanResponder,
onStartShouldSetPanResponderCapture: onMoveShouldSetPanResponder
})
export {
animatedPosition,
panGesture
}
Let's go through the code:
STARTING_POSITION
— is equal to-250
. This value is the starting position and our BottomSheet is partly hidden.animatedPosition
— is the starting point to use ourAnimated
API. We just need to initialize a new instance to use it with our<Animated.View />
component.animateMove
— helper function. A way to keep our code DRY. Basically, it is how we animate our movement. Notice we usetension
as animation behaviour. There are plenty of alternatives to this behaviour that allow us to simulate a little jumping (physic) of the BottomSheet.movementValue
— helper function. Used to calculate how far the user moves his finger.onMoveShouldSetPanResponder
— important function. Define if we will pass handling to PanResponder or deeper into the component tree. For example, if you will have a button under PanResponder handlers it will intercept any presses and your button will NEVER be clicked, so we need to avoid PanResponder interception, and define what is press and what is swipe. Basically, this is a way how we understand gesture false move.onPanResponderMove
— handler to animate on gesture move and forbid animation above BottomSheet maximum top position.onPanResponderRelease
— this can be ignored in regular cases, but when we are doing BottomSheet we should use this to give a directive to our BottomSheet on how it should behave when we release our finger. Basically, it’s a calculation, if we move our BottomSheet more then 1/3 it should be hidden, or else it should be visible.
Epilogue
All of this is easy to accomplish, and fully customizable put anything that you like into the content of the BottomSheet.
Code examples you can find on GitHub repo.
There is an example for both, stateful and stateless, components.
Have a great time!
Follow me for more!