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.
What worked for me was using fireGestureHandler and getByGestureTestId imported from react-native-gesture-handler/jest-utils (docs).
I had to put the tag on the Gesture
const panGesture = Gesture.Pan().withTestId('pan-gesture');
and then i was able get it in a test like so
fireGestureHandler<PanGesture>(getByGestureTestId('pan-gesture'), [
{ state: State.BEGAN, translationX: 0 },
...
{ state: State.END, translationX: 30, translationY: 20 }
]);
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With