Frontend/React-Native

[React Native] Star Rating 만들기

w00se 2021. 7. 17. 13:25

https://pixabay.com/ko/photos/놀이터-회전-신장-매력-5188459/

 

별점(star rating)은 상품이나 컨텐츠에 대한 유저의 만족도를 간단히 수치화할 수 있는 수단입니다.

 

이번 글에서는 PanResponder로 드래그 가능한 별점의 구현 방법을 정리했습니다.

 

* 별점 기능을 구현하기 위해 react-native-reanimated 라이브러리를 사용했습니다.

라이브러리 설치가 필요하신 분은 아래 링크를 참고하면 좋을 거 같아요☺️

https://coding-w00se.tistory.com/39

 

구현 예시

 

별점 만들기 예시

특징

1. 터치 또는 드래그 제스처로 별점을 표현할 수 있습니다.

2. 저장되는 별점은 0 ~ 5 사이의 0.5 간격의 수이며, 올림 함수(Math.ceil)를 이용하여 계단 함수처럼 구현했습니다.

 

구현해야 할 효과 및 기능

위 예시를 위해 구현해야 할 기능은 아래와 같습니다.

 

1. PanRedponder를 이용하여 사용자의 드래그 제스처 인식하기

2. 드래그에 따라 별점 색 채우기

3. 별점 점수 계산하기

 

0. View 구조 만들기 및 이미지 준비

StarRatingTestScreen.js

StarRating component가 사용될 스크린입니다.

import React from 'react';
import {
    View,
    StyleSheet,
    Text
} from 'react-native';
import StarRating from '../components/StarRating';

function StarRatingTestScreen(props) {
    return (
        <View style={styles.rootContainer}>
            <StarRating />
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
        backgroundColor: "#ffffff",
        justifyContent: "center",
        alignItems: "center",
    }
});

export default StarRatingTestScreen

 

StarRating.js

별점을 기능을  구현할 컴포넌트입니다.

import React from 'react';
import {
    View,
} from 'react-native';
import TransparentStarGroupSvg from '../assets/images/transparentStarGroup.svg';

function StarRating (props) {
    return (
        <View>
            <View>
                <TransparentStarGroupSvg/>
            </View>
        </View>
    );
}

export default StarRating;

 

이미지

 

이미지 예시

별점 기능을 구현하기 위해서는 위에 있는 <사용된 이미지>처럼 별 내부가 비어 있는 이미지가 필요합니다.

 

이미지의 설명은 아래와 같습니다.

<설명 이미지>에서 빨간색으로 채워진 부분 : 빨간색 부분은 <사용된 이미지>에서 svg의 path로 테두리를 그리고 stroke="#ffffff" fill="#ffffff" 속성을 적용했습니다.

=> 선의 색을 흰색으로 지정하고 흰색을 채워 넣었습니다.

 

<설명 이미지>에서 파란색 별 모양 테두리 부분 : 파란색 별 모양 테두리 부분은 <사용된 이미지>에서 svg의 path로 테두리를 그리고 fill="rgba(255, 216, 0, 0.15)" 속성을 사용했습니다.

=> 별 모양의 색은 투명한 노란색으로 지정했습니다. 별 모양은 색을 지정하지 않거나 반드시 투명도(opacity)를 설정해야 별점 기능을 구현할 수 있습니다.

 

* 이미지만 다른 모양으로 바꾸면 다른 모양으로 점수를 표현할 수 있습니다.

 

1. PanRedponder를 이용하여 사용자의 드래그 제스처 인식하기

사용자의 드래그는 이미지의 부모 View에 PanResponder를 지정하여 인식합니다.

import React, { useMemo } from 'react';
import {
    View,
    StyleSheet,
    PanResponder,
} from 'react-native';
import Animated from 'react-native-reanimated';
import TransparentStarGroupSvg from '../assets/images/transparentStarGroup.svg';

function StarRating (props) {
    const panResponders = useMemo(()=> PanResponder.create({
        onStartShouldSetPanResponder: () => true,
        onMoveShouldSetPanResponder: () => false,
        onPanResponderTerminationRequest: () => false,
        onPanResponderGrant: (event, gestureState) => {
            // 터치됐을 때 호출되는 이벤트로, 이곳에서 초기 위치 저장하며 
            // 해당 컴포넌트가 scrollView와 함께 사용될 경우 scrollView에 속성을 scrollEnable를 false로 지정합니다.
        },
        onPanResponderMove: (event, gestureState) => {
            // 사용자가 드래그 했을 때 호출되는 이벤트로, 이곳에서 드래그한 위치를 저장합니다.

        },
        onPanResponderRelease: (event, gestureState) => {
            // 사용자가 손을 땠을 때 호출되는 이벤트로, 이곳에서 최종 별점을 계산합니다.
            // 해당 컴포넌트가 scrollView와 함께 사용될 경우, 해당 이벤트 마지막 줄에 scrollView의 scrollEnable 속성을 true로 저장합니다.
        },
        onPanResponderTerminate: (event, gestureState) => {
            // responder를 다른 컴포넌트에 빼았겼을 때 호출되는 이벤트로, 이곳에서 최종 별점을 계산합니다.
            // 해당 이벤트는 주로 android에서 해당 컴포넌트를 scrollView와 함께 사용했을 떄 발생됩니다.
            // 해당 컴포넌트가 scrollView와 함께 사용될 경우, 해당 이벤트 마지막 줄에 scrollView의 scrollEnable 속성을 true로 저장합니다.
        },
        onShouldBlockNativeResponder: (evt, gestureState) => { return false; }
    }), []);

    return (
        <View style={styles.rootContainer}>
            <View>
                <Animated.View style={[ styles.starBackground, ]} pointerEvents="none"/>
                <View 
                    {...panResponders.panHandlers}
                >
                    <TransparentStarGroupSvg/>
                </View>
            </View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flexDirection: "row",
        justifyContent: "space-between",
    },
    starBackground: {
        position: "absolute",
        backgroundColor: "#ffd800",
        height: "100%",
        minWidth: 0,
        maxWidth: 202,
    },
})

export default StarRating;

 

2. 드래그에 따라 별점 색 채우기

별점 표현은 Animated.View의 스타일을 조절하여 구현합니다.

Animated.View의 position을 absolute로 지정하여 별 이미지와 포개지게 구현하고 width는 사용자의 드래그에 따라 조절되게 animated style을 지정합니다.

마지막으로 Animated.View에 background style에 색을 지정하면 별점 이미지의 드래그 위치에 따라 포개진 Animated.View의 width가 조절되어 별점이 채워지는 효과를 구현할 수 있습니다.

 

import React, { useMemo, useState, useCallback } from 'react';
import {
    View,
    StyleSheet,
    PanResponder,
} from 'react-native';
import Animated, {
    useSharedValue,
    useAnimatedStyle,
    useDerivedValue,
    interpolate,
    Extrapolate,
    withTiming,
} from 'react-native-reanimated';
import TransparentStarGroupSvg from '../assets/images/transparentStarGroup.svg';

function StarRating (props) {
    const [ rootViewPosX, setRootViewPosX ] = useState(0);
    const [ starRatingImageWidth, setStarRatingImageWidth ] = useState(0);
    const panX = useSharedValue(0); // 사용자의 드래그 위치를 저장하는 변수
    
    const starRatingWidth = useDerivedValue(()=>{ // 실제 별점의 너비를 표현하는 변수로 animated.View의 width style에 사용되는 값 입니다.
        return interpolate(panX.value, [0, starRatingImageWidth], [0, starRatingImageWidth], Extrapolate.CLAMP);
    },[ starRatingImageWidth ]);

    const animatedStyle = useAnimatedStyle(()=>{
        return {
            width: starRatingWidth.value
        }
    }, []);

    const panResponders = useMemo(()=> PanResponder.create({
        onStartShouldSetPanResponder: () => true,
        onMoveShouldSetPanResponder: () => false,
        onPanResponderTerminationRequest: () => false,
        onPanResponderGrant: (event, gestureState) => {
            panX.value = gestureState.x0 + gestureState.dx - rootViewPosX; // 사용자의 초기 터치 위치 + 이동 위치 - rootView의 x 위치
        },
        onPanResponderMove: (event, gestureState) => {
            panX.value = gestureState.x0 + gestureState.dx - rootViewPosX; // 사용자의 초기 터치 위치 + 이동 위치 - rootView의 x 위치
        },
        onPanResponderRelease: (event, gestureState) => {
        },
        onPanResponderTerminate: (event, gestureState) => {
        },
        onShouldBlockNativeResponder: (evt, gestureState) => { return false; }
    }), [ rootViewPosX, starRatingImageWidth ]);

    const rootContainerOnLayout = useCallback((e)=>{
        // root Component onLayout으로 root View의 position x를 계산합니다.
        const { x } = e.nativeEvent.layout;

        setRootViewPosX(x);
    }, []);

    const starRatingImageOnLayout = useCallback((e)=>{
        // star Rating Image onLayout으로 별 이미지의 width를 계산합니다.
        const { width } = e.nativeEvent.layout;

        setStarRatingImageWidth(width);
    }, []);
    
    return (
        <View style={styles.rootContainer} onLayout={rootContainerOnLayout}>
            <View>
                <Animated.View style={[ styles.starBackground, animatedStyle]} pointerEvents="none"/>
                <View 
                    onLayout={starRatingImageOnLayout}
                    {...panResponders.panHandlers}
                >
                    <TransparentStarGroupSvg/>
                </View>
            </View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flexDirection: "row",
        justifyContent: "space-between",
    },
    starBackground: {
        position: "absolute",
        backgroundColor: "#ffd800",
        height: "100%",
        minWidth: 0,
        maxWidth: 202,
    },
})

export default StarRating;

 

여기까지 구현하면 아래 예시처럼 드래그에 따라 별점이 채워지는 효과를 구현할 수 있습니다.

 

2. 드래그에 따라 별점 색 채우기 예시

3. 별점 점수 계산하기

별점 계산은 PanResponder의 onPanResponderRelease와 onPanResponderTerminate에서 수행됩니다.

저는 별점을 올림으로 처리했기 때문에 Math.ceil을 이용해서 구현했습니다.

공식은 아래와 같습니다.

별점 = Math.ceil(현재 드래그된 위치 / step ) / 2

* step은 별점을 나누는 단위로 (총 별점 이미지 너비) * (단위 별점 비율)로 계산됩니다.

step의 계산 예시는 아래와 같습니다.
1. 총 별점 이미지 너비 = 200, 별점 단위 = 0.5 단위
step = 200 * 0.1

2. 총 별점 이미지 너비 = 200, 별점 단위 = 1 단위
step = 200 * 0.2

 

해당 코드를 포함한 전체 코드는 아래와 같습니다.

 

전체 코드

StarRating.js

: 별점 텍스트를 표시하면서 view style에 약간에 변화가 생겼습니다.

import React, { useMemo, useState, useCallback } from 'react';
import {
    View,
    StyleSheet,
    PanResponder,
    Text
} from 'react-native';
import Animated, {
    useSharedValue,
    useAnimatedStyle,
    useDerivedValue,
    interpolate,
    Extrapolate,
    withTiming,
} from 'react-native-reanimated';
import TransparentStarGroupSvg from '../assets/images/transparentStarGroup.svg';

function StarRating (props) {
    const [ rootViewPosX, setRootViewPosX ] = useState(0);
    const [ starRating, setStarRating ] = useState(0); // 별점을 저장하는 state
    const [ starRatingImageWidth, setStarRatingImageWidth ] = useState(0);
    const step = useMemo(() => starRatingImageWidth * 0.1, [ starRatingImageWidth ]);
    const panX = useSharedValue(0); // 사용자의 드래그 위치를 저장하는 변수
    
    const starRatingWidth = useDerivedValue(()=>{ // 실제 별점의 너비를 표현하는 변수로 animated.View의 width style에 사용되는 값 입니다.
        return interpolate(panX.value, [0, starRatingImageWidth], [0, starRatingImageWidth], Extrapolate.CLAMP);
    },[ starRatingImageWidth ]);

    const animatedStyle = useAnimatedStyle(()=>{
        return {
            width: starRatingWidth.value
        }
    }, []);

    const panResponders = useMemo(()=> PanResponder.create({
        onStartShouldSetPanResponder: () => true,
        onMoveShouldSetPanResponder: () => false,
        onPanResponderTerminationRequest: () => false,
        onPanResponderGrant: (event, gestureState) => {
            // props.setScrollEnabled(false); // scrollView와 함께 사용할 때 scrollEnabled를 false로 지정하는 게 좋습니다.
            // props.setPointerEvent("none"); // 다른 component에 의해 responder가 전환된다면 grant 발생 시 다른 컴포넌트의 pointerEvent에 none을 지정하는 게 좋습니다.
            panX.value = gestureState.x0 + gestureState.dx - rootViewPosX; // 사용자의 초기 터치 위치 + 이동 위치 - rootView의 x 위치
        },
        onPanResponderMove: (event, gestureState) => {
            panX.value = gestureState.x0 + gestureState.dx - rootViewPosX;
        },
        onPanResponderRelease: (event, gestureState) => {
            const ciledValue = Math.ceil(starRatingWidth.value / step)

            panX.value = withTiming(ciledValue * step, { duration: 100 }); // 별점에 맞게 Animated.View의 width를 조절합니다.
            setStarRating(ciledValue / 2); // 별점을 저장합니다.

            // props.setScrollEnabled(true); // onPanResponderGrant와 반대로 설정합니다.
            // props.setPointerEvent("auto");
        },
        onPanResponderTerminate: (event, gestureState) => {
            const ciledValue = Math.ceil(starRatingWidth.value / step)

            panX.value = withTiming(ciledValue * step, { duration: 100 });
            setStarRating(ciledValue / 2);

            // props.setScrollEnabled(true); // onPanResponderGrant와 반대로 설정합니다.
            // props.setPointerEvent("auto");
        },
        onShouldBlockNativeResponder: (evt, gestureState) => { return false; }
    }), [ rootViewPosX, starRatingImageWidth ]);

    const rootContainerOnLayout = useCallback((e)=>{
        // root Component onLayout으로 root View의 position x를 계산합니다.
        const { x } = e.nativeEvent.layout;

        setRootViewPosX(x);
    }, []);

    const starRatingImageOnLayout = useCallback((e)=>{
        // star Rating Image onLayout으로 별 이미지의 width를 계산합니다.
        const { width } = e.nativeEvent.layout;

        setStarRatingImageWidth(width);
    }, []);
    
    return (
        <View style={styles.rootContainer} onLayout={rootContainerOnLayout}>
            <Text style={styles.starRatingText}>
                {`현재 별점 ${starRating}`}
            </Text>
            <View style={styles.starRatingContainer}>
                <Animated.View style={[ styles.starBackground, animatedStyle]} pointerEvents="none"/>
                <View 
                    onLayout={starRatingImageOnLayout}
                    {...panResponders.panHandlers}
                >
                    <TransparentStarGroupSvg/>
                </View>
            </View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        alignItems: "center"
    },
    starRatingText: {
        fontFamily: "AppleSDGothicNeo-Regular",
        fontSize: 12
    },
    starRatingContainer: {
        flexDirection: "row",
    },
    starBackground: {
        position: "absolute",
        backgroundColor: "#ffd800",
        height: "100%",
        minWidth: 0,
        maxWidth: 202,
    },
})

export default StarRating;

 

여기까지 구현하면 위의 구현 예시처럼 구현이 됩니다.


읽어 주셔서 감사합니다 :)

잘못된 부분이 있다면 댓글로 편히 알려주세요😊