Animated Book Star Rating in React Native

January 13, 2021 - 5 min read

Animated Book Start Rating in React Native

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.