별점(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;
여기까지 구현하면 아래 예시처럼 드래그에 따라 별점이 채워지는 효과를 구현할 수 있습니다.
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;
여기까지 구현하면 위의 구현 예시처럼 구현이 됩니다.
읽어 주셔서 감사합니다 :)
잘못된 부분이 있다면 댓글로 편히 알려주세요😊
'Frontend > React-Native' 카테고리의 다른 글
[React Native] Collapsible Tab View 만들기 (10) | 2021.09.18 |
---|---|
[React Native] Animated Interpolate extrapolate (0) | 2021.08.16 |
[React Native] Scroll bar 가운데로 오는 이슈 (2) | 2021.07.13 |
[React Native] Collapsible View 만들기 (0) | 2021.07.11 |
[React Native] react-native-reanimated v2 설치 (4) | 2021.06.22 |