Animations can make already good apps look and feel even better. It can improve the user experience by making actions feel more natural or living. One use case of animations are modals or pop ups, that dynamically display content on the screen. I will describe how you can create a modal in React Native, that opens and closes from/to the bottom.
In addition, the modal will have a vector-based star rating box, in order to make the user rate an item (in this case books). Also, the backdrop of the modal blurs the content behind it. This should make the modal fell more natural. Works on Android and iOS. You can find the full source code on Github.
The Modal
The Modal consists of several views, but the root should be positioned absolute, otherwise you may run into problems with your layout. Also, the modal needs an Animated.View
component, that contains the PanResponder
functionality, in order to handle the swipe events. For the blur effect, which is rather simple to achieve, @react-native-community/blur
is being used. Note that the entire screen will be covered by the pan handler, since we also want to catch if the user clicked on the backdrop/blur view.
import { Animated, StyleSheet } from 'react-native'; | |
import { BlurView } from '@react-native-community/blur'; | |
<Animated.View | |
{...modalResponder.panHandlers} | |
style={StyleSheet.absoluteFillObject}> | |
<BlurView | |
style={StyleSheet.absoluteFillObject} | |
blurType="light" | |
blurAmount={5} | |
reducedTransparencyFallbackColor="white" | |
/> | |
... |
When it comes to the actual modal window, I set the height it to be 25% of the screen's height, as defined in MODAL_HEIGHT
. As you will see later, we track the amount of pixels that the modal has been swiped down in a React reference as Animated.ValueXY
.
Using the reference value, we can change the opacity of the modal window, depending on how far the modal has transitioned. For this, we will use interpolation, mapping the position (translation) of the modal window to an opacity value between 1 (fully open) and 0.5 (modal is out of screen).
... | |
<Animated.View | |
style={{ | |
opacity: pan.y.interpolate({ | |
inputRange: [screenHeight - MODAL_HEIGHT, screenHeight], | |
outputRange: [1, 0.5], | |
}), | |
transform: [ | |
{ | |
translateY: pan.y, | |
}, | |
], | |
}}> | |
<View | |
style={{ | |
width: '100%', | |
height: MODAL_HEIGHT | |
... |
Jumping forward to the actual content of the modal window, which will be a row of stars that the user can select for rating books. In order to know which star is being selected and at which part, we will use another PanResponder
. We will do this because it makes position tracking much easier and reliable, than with just one responder. You also the see a onLayout
callback, which is used to keep track of the star row's width, as later described. It had to be a React reference and not a state, because it is used in a PanResponder
and thus would not work otherwise.
... | |
<Animated.View | |
onLayout={(e) => { | |
animatedWidth.current = e.nativeEvent.layout.width; | |
}} | |
style={{ flexDirection: 'row' }} | |
{...starPanResponder.panHandlers}> | |
{Array.from({ length: 5 }).map((_, i) => { | |
return ( | |
<Star | |
key={i} | |
size={70} | |
distance={8} | |
offset={offset - i} // local state | |
/> | |
); | |
})} | |
</Animated.View> |
The Modal Responder allows you to keep track of touches inside the whole modal (except the star row). Before we actually allow a gesture to be tracked, we check if the touch is inside the window area (25% height). Otherwise, the touch would hit the backdrop area. Also, when the swiping down of the modal ends, we either close it completely or keep it open, defined by being less than 50% closed already. Move events will change the modal position, as described later.
const modalResponder = React.useRef( | |
PanResponder.create({ | |
onStartShouldSetPanResponder: (e) => { | |
// check if touch is in the modal area | |
if (e.nativeEvent.pageY > height - MODAL_HEIGHT) { | |
return true; | |
} | |
closeAnim(); | |
return false; | |
}, | |
onPanResponderMove: (_, gs) => { | |
changeModalPosition(gs); | |
}, | |
onPanResponderRelease: (_, { dy }) => { | |
if (dy < MODAL_HEIGHT / 2) { | |
openAnim(); | |
return; | |
} | |
closeAnim(); | |
}, | |
}), | |
).current; |
For the Star Responder we will add the same behavior for when the gesture ends, as with the modal responder. But for the touch and move events, the star rating (here offset) is being calculated and set. If the user swipes down over a star, the change in y
is being checked, and if its greater than a threshold, the modal position will change instead.
const starPanResponder = React.useRef( | |
PanResponder.create({ | |
onStartShouldSetPanResponder: (e, gs) => { | |
changeOffset(e); // start tracking star rate change | |
return true; | |
}, | |
onPanResponderMove: (e, gs) => { | |
// user swiped down on a star | |
if (gs.dy > 50) { | |
changeModalPosition(gs); | |
return; | |
} | |
changeOffset(e); | |
}, | |
onPanResponderRelease: (_, { dy }) => { | |
if (dy < MODAL_HEIGHT / 2) { | |
openAnim(); | |
} else { | |
closeAnim(); | |
} | |
}, | |
}), | |
).current; |
Spring Animation
In order to achieve a natural, slight bounce animation of the modal window, we will use a spring animation. When the modal window opens, it moves from the bottom of the screen up by its height. This is why we, to calculate that position, subtract the screen height (which is the full modal height) minus the targeted modal window height (25% of that size). Closing the window means moving it to the bottom, out of the screen, meaning the screen's height.
const openAnim = () => { | |
Animated.spring(pan.y, { | |
toValue: screenHeight - MODAL_HEIGHT, | |
bounciness: 0, | |
useNativeDriver: true, | |
}).start(); | |
}; | |
const closeAnim = () => { | |
Animated.spring(pan.y, { | |
toValue: screenHeight, | |
useNativeDriver: true, | |
}).start(); | |
// callback (to toggle state) | |
props.onClose(); | |
}; |
Tracking the Modal Position
The PanResponder
fires events for touches that the user does on the modal. To track that position, we take the accumulated distance of the gesture since the touch started, as saved in dy
. This is then saved as an animated value in a React reference and used for translateY
and opacity
, as mentioned before.
const changeModalPosition = React.useCallback( | |
(gs: PanResponderGestureState) => { | |
const value = screenHeight - MODAL_HEIGHT + gs.dy; | |
// prevent dragging too high or too low | |
if (value >= screenHeight || value < screenHeight - MODAL_HEIGHT) { | |
return; | |
} | |
pan.y.setValue(value); | |
}, | |
[], | |
); |
The Star
As the user touches the stars, we also want them to be able to select half stars. This requires a gesture tracking and evaluation, otherwise we could just a TouchableOpacity
or similar to track clicks on a star. The row of stars will specifically track touches in this area. Somehow, we need to check the x position of where the user touches the star row.
With the pageX
value, we can track the x position of where the user touches, in relation to the screen. There is an alternative, called locationX
, but that caused problems on Android. To know which star is being touched, we need to know its position on the phone's screen.
This example is rather simple, so the calculation required knowing the star row width, as well as a single star size (plus its margin distance). If the user touches the first half of a star, its value is being evaluated to 0.5. Otherwise, the star would be selected as full.
const changeOffset = React.useCallback((e: GestureResponderEvent) => { | |
const { nativeEvent } = e; | |
const distance = (screenWidth - animatedWidth.current) / 2; // view is centered | |
const starSize = animatedWidth.current / 5; // 5 stars | |
let starVal = Number((nativeEvent.pageX - distance) / starSize); | |
const rest = starVal - Math.trunc(starVal); | |
if (rest <= 0.5) { | |
starVal = Math.trunc(starVal); | |
} else { | |
starVal = Math.trunc(starVal) + 0.5; | |
} | |
setOffset(starVal); | |
}, []); |
In order to easily scale and fill a star, we will use a vector-graphics based solution via the react-native-svg
library. This allows filling the star with a linear gradient, so that we can even fill a star by 27%, if needed. The LinearGradient
will have two Stop
definitions, which then adjust the filling via the offset
prop.
Each star can then be filled via passing an offset
with a range between [0, 1]
. This then means you know how much to color each star, since its index is known via the root component. Simple subtraction then gives you the offset
value.
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; | |
<Svg {...props}> | |
<Defs> | |
<LinearGradient id="grad" x1="0" y1="0" x2="1" y2="0"> | |
<Stop offset={props.offset} stopColor="yellow" stopOpacity="1" /> | |
<Stop offset={props.offset} stopColor="grey" stopOpacity="1" /> | |
</LinearGradient> | |
</Defs> | |
<Path | |
fill="url(#grad)" | |
d="M479.471 9.49859C485.19 -2.89935 502.81 -2.89931 508.529 9.49863L642.603 300.17C644.933 305.223 649.722 308.702 655.248 309.357L973.124 347.047C986.682 348.654 992.127 365.412 982.103 374.682L747.089 592.016C743.004 595.794 741.175 601.424 742.259 606.882L804.644 920.846C807.305 934.238 793.049 944.595 781.135 937.926L501.815 781.574C496.959 778.857 491.041 778.857 486.185 781.574L206.865 937.926C194.951 944.595 180.695 934.238 183.356 920.846L245.741 606.882C246.825 601.424 244.996 595.794 240.911 592.016L5.89677 374.682C-4.12723 365.412 1.31786 348.654 14.8762 347.047L332.752 309.357C338.278 308.702 343.067 305.223 345.397 300.17L479.471 9.49859Z" | |
/> | |
</Svg> |
Originally published at https://mariusreimer.com on January 13, 2021.