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.

0

There are 0 best solutions below