I have created a swipeable rating component:
import FontistiIcon from '@expo/vector-icons/Fontisto';
import React, { useCallback, useMemo } from 'react';
import { LayoutRectangle, View } from 'react-native';
import {
Gesture,
GestureDetector,
TouchableOpacity,
} from 'react-native-gesture-handler';
import { useTheme } from '@theme';
import { roundToStep } from '@utils';
import { useStyle } from './numerical-rating.styles';
import { RatingProps } from './numerical-rating.type';
const NumericalRating: React.FC<RatingProps> = props => {
const {
scale = 5,
testID = 'TestID__component-NumericalRating',
starTestID = 'TestID__component-NumericalRating-star',
isFractional = false,
onChange,
value,
} = props;
const styles = useStyle();
const theme = useTheme();
const [ratingContainerLayout, setRatingContainerLayout] =
React.useState<LayoutRectangle | null>(null);
// if scale is not a whole number, then we need to round it to the nearest whole number
const scaleRounded = Math.round(scale);
const scaleList = Array.from(Array(scaleRounded).keys(), x => x + 1);
// Create [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]
const fractionalScaleList = scaleList.reduce<number[][]>(
(acc, curr) => [...acc, [curr - 0.5, curr]],
[],
);
const iconSize = theme.t.moderateScale(58);
const iconWidth = Math.floor(iconSize / 2);
// value should be between the range of 0 and scale
const selectedRating = Math.max(0, Math.min(value, scale));
const [draggedRating, setDraggedRating] = React.useState(selectedRating);
const onRatingTap = (rating: number) => () => {
onChange(rating);
setDraggedRating(rating);
};
const getRatingByPosition = useCallback(
(position: number) => {
if (ratingContainerLayout?.width) {
// get the rating icon size based on the number of stars and width of the container
const ratingIconSize = ratingContainerLayout.width / scaleRounded;
// Calculate the rating value based on the position of the finger
const calculatedRatingValue = position / ratingIconSize;
return roundToStep(calculatedRatingValue, isFractional ? 0.5 : 1);
}
return 0;
},
[isFractional, ratingContainerLayout, scaleRounded],
);
/**
* ONLY IF YOU NEED IT
* 1. shouldCancelWhenOutside - should cancel the gesture if the user moves their
* finger outside of the rating container
*
* 2. failOffsetY([0, 0]) - should fail the gesture if the user moves their finger
* vertically. First zero indicates the minimum offset to the top, second zero
* indicates the minimum offset to the bottom. We have set both to zero because
* we want the user to be able to move their finger vertically so that they could
* scroll the screen
*/
const gesture = useMemo(
() =>
Gesture.Pan()
.runOnJS(true)
.onUpdate(e => {
setDraggedRating(getRatingByPosition(e.x));
})
.onEnd(e => {
onChange(getRatingByPosition(e.x));
}),
[getRatingByPosition, onChange],
);
// Render the full icon rating
const renderScale = () =>
scaleList.map(rating => (
<TouchableOpacity
key={rating}
onPress={onRatingTap(rating)}
testID={starTestID}
accessibilityState={{
selected: draggedRating >= rating,
}}
>
<View style={styles.scale}>
<FontistiIcon
name="star"
size={iconSize}
color={
draggedRating >= rating
? theme.t.palette.accents.color2
: theme.t.palette.accents.color4
}
/>
</View>
</TouchableOpacity>
));
// fractionalScaleList = e.g [[0.5, 1], [1.5, 2], [2.5, 3], [3.5, 4], [4.5, 5]]
// Render the fractional icon rating
const renderFractionalScale = () =>
fractionalScaleList.map((ratingPair: number[], index) => (
<View style={[styles.ratingPair, styles.scale]} key={index}>
{/* ratingPair = e.g [0.5, 1] */}
{ratingPair.map((rating: number) => (
<View key={rating} style={rating % 1 === 0 ? styles.rightHalf : {}}>
<TouchableOpacity
onPress={onRatingTap(rating)}
testID={starTestID}
accessibilityState={{
selected: draggedRating >= rating,
}}
>
<FontistiIcon
name="star-half"
size={iconSize}
style={{
width: iconWidth,
}}
color={
draggedRating >= rating
? theme.t.palette.accents.color2
: theme.t.palette.accents.color4
}
/>
</TouchableOpacity>
</View>
))}
</View>
));
// Render the rating scale based on the isFractional prop
const renderRating = () => {
if (isFractional) {
return renderFractionalScale();
}
return renderScale();
};
return (
<GestureDetector gesture={gesture}>
{/* This View helps us in letting the panning continue even after
reaching the end */}
<View>
{/* This View is contained within the width of the rendered stars */}
<View
style={styles.container}
testID={testID}
onLayout={e => setRatingContainerLayout(e.nativeEvent.layout)}
>
{renderRating()}
</View>
</View>
</GestureDetector>
);
};
export default NumericalRating;
I am trying to test the pan gesture using fireGestureHandler
from react-native-gesture-handler/jest-utils
. Below, you can see the code I have written:
it('should select the correct star on swipe', () => {
const onChange = jest.fn();
render(<NumericalRating value={2} onChange={onChange} />);
const starContainer = screen.getByTestId(
'TestID__component-NumericalRating',
);
fireGestureHandler<PanGesture>(starContainer, [
{ x: 5, y: 15 },
{ x: 6, y: 16 },
{ x: 7, y: 17 },
]);
expect(onChange).toHaveBeenCalledWith(1);
});
The component doesn't detect the pan gesture. react-native-gesture-handler
doesn't have a lot of examples showing how we test pan gestures. The piece of code related to the pan gesture reduces my code coverage.