1편에서 만든 TutorialProvider에 사용된 Tutorial 컴포넌트를 만들어 보겠습니다.
Tutorial 컴포넌트의 형태입니다. HoleInfo 타입은 1편 마지막 부분을 참조해 주세요.
| type TutorialProps = { | |
| holes: HoleInfo[]; | |
| onLastPress: () => void; | |
| whiteMode?: boolean | |
| }; | |
| export default ({holes: holesProp, onLastPress, whiteMode}: TutorialProps) => { | |
| ... | |
| } |
우선 4개의 훅이 사용됩니다. DescriptionPosition 타입은 1편 마지막 부분을 참조해 주세요.
| const index = useRef(0); | |
| const [holes, setHoles] = useState<RNHole[]>([firstHole]); | |
| const [descriptionText, setDescriptionText] = useState(''); | |
| const [descriptionPosition, setDescriptionPosition] = useState<DescriptionPosition>({ | |
| top: undefined, | |
| right: undefined, | |
| bottom: undefined, | |
| left: undefined, | |
| }); |
HoleView는 한 번에 여러개의 구멍을 표시할 수 있습니다. 하지만 저는 한 번에 한 개의 구멍이 터치할 때마다 이동하는 UI를 구현하였습니다. 그래서 holes는 항상 요소가 1개인 배열입니다.
firstHole는 매개값으로 입력되는 holes에서 첫번째 요소의 x, y 좌표에서 사이즈만 0으로 변경한 더미 값입니다. 처음에 구멍을 표시할 때 점점 커지는 효과를 넣기 위해서 만들었습니다. 마지막에 전체 코드에서 확인할 수 있습니다.
그리고 descriptionText와 descriptionPosition은 텍스트와 텍스트의 위치를 조절하기 위해 사용됩니다. 매개값으로 입력되는 HoleInfo 추출할 수 있습니다.
다음은 구멍과 텍스트를 변경하는 함수입니다. 매개변수로 전달된 holes(HoleInfo[])에서 꺼낸 아이템(HoleInfo)을 입력합니다. 튜토리얼을 시작할 때, 터치할 때, 마지막으로 터치할 때 로직이 다르지만, 이 부분은 동일하기 때문에, 중복을 방지하기 위해서 따로 함수를 만들었습니다.
| const replaceHole = useCallback(async (hole: HoleInfo) => { | |
| const {holePosition: {x, y, size, borderRadius}, descriptionPosition, descriptionText} = hole; | |
| setDescriptionText(t(descriptionText)); | |
| setDescriptionPosition(descriptionPosition); | |
| if(isIOS) { | |
| await delay(25); | |
| } | |
| setHoles([{x, y, width: size, height: size, borderRadius: borderRadius !== undefined ? borderRadius : size/2}]); | |
| }, []); |
iOS에서는 setHoles와 다른 훅을 동시에 실행하면 애니메이션이 작동하지 않는 문제가 발생해서 25ms의 딜레이를 주었습니다.
튜토리얼이 시작할 때 실행되는 훅과 튜토리얼을 터치했을 때 실행하는 함수입니다.
index.current !== lastIndex일 때는 안드로이드에서 추가 코드가 있지만 생략했습니다. 안드로이드에서추가 코드를 실행시켜주지 않으면 구멍이 작아질 때, borderRadius가 이상해 지더라구요. 아래 전체코드에서 확인할 수 있습니다.
| useEffect(() => { | |
| setTimeout(() => { | |
| replaceHole(holesProp[0]); | |
| }, 250); | |
| return () => {} | |
| }, []); | |
| const onPress = useCallback(async () => { | |
| if(index.current !== lastIndex) { | |
| replaceHole(holesProp[index.current++]); | |
| } | |
| else { | |
| const {holePosition} = holesProp[lastIndex]; | |
| replaceHole({ | |
| holePosition: { ...holePosition, size: 0, borderRadius: isAndroid ? holePosition.size/2 : undefined}, | |
| descriptionPosition: {}, | |
| descriptionText: '' | |
| }) | |
| await delay(250); | |
| onLastPress(); | |
| } | |
| }, []); |
처음에 언급했다시피 사이즈가 0인 firstHole에서 hole[0]으로 변경하면 첫 번째 구멍이 점점 커지면서 나타나게 됩니다.
마지막 인덱스에서 터치하게 되면 마지막 아이템의 x, y 좌표에 사이즈만 0인 lastHole을 새로 만들어줍니다. 그리고 튜토리얼 Context로 부터 전달받은 onLastPress 함수를 실행시킵니다. 여기에는 Tutorial 컴포넌트를 제거하는 코드가 포함되어 있습니다. (1편 전체코드 참조)
마지막으로 안드로이드에서 뒤로가기 버튼을 금지해 줍니다.
| useEffect(() => { | |
| if(isIOS) { | |
| return | |
| } | |
| const hardwareBackPress = (): boolean => { | |
| return true // 안드로이드 뒤로가기 실행 금지 | |
| } | |
| const backHandler = BackHandler.addEventListener('hardwareBackPress', hardwareBackPress) | |
| return () => { | |
| if(isIOS) { | |
| return | |
| } | |
| backHandler.remove() | |
| } | |
| }, []) |
다음은 랜더링 부분입니다.
react-native-hole-view 제작자가 iOS와 안드로이드에서 HoleView를 터치했을 때 결과가 다르다고 했기 때문에 애초에 외부에서 HoleView를 제어했습니다.
| <TouchableWithoutFeedback onPress={onPress}> | |
| <Container> | |
| <HoleView | |
| animation={{timingFunction: ERNHoleViewTimingFunction.EASE_OUT, duration: 200}} | |
| holes={holes} | |
| style={{ | |
| backgroundColor: whiteMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.5)' | |
| }}> | |
| <Description | |
| style={{ | |
| ...descriptionPosition, | |
| textAlign: descriptionPosition.right === undefined ? 'left' : 'right', | |
| fontSize: languageTag === "ko" ? 20 : 18, | |
| color: whiteMode ? '#333333' : '#ffffff' | |
| }}> | |
| {descriptionText} | |
| </Description> | |
| </HoleView> | |
| </Container> | |
| </TouchableWithoutFeedback> |
TouchableWithoutFeedback은 한 개의 View 컴포넌트만 받을 수 있기 때문에 HoleView를 더미 컴포넌트인 Container로 감쌌습니다.
컴포넌트는 styled-components를 사용해서 만들었습니다.
| const Container = styled.View` | |
| position: absolute; | |
| width: ${width}px; | |
| height: ${height}px; | |
| ` | |
| const HoleView = styled(RNHoleView)` | |
| position: absolute; | |
| width: ${width}px; | |
| height: ${height}px; | |
| justify-content: center; | |
| align-items: center; | |
| ` | |
| const Description = styled.Text` | |
| position: absolute; | |
| color: #ffffff; | |
| font-weight: bold; | |
| ` |
전체코드
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |
| import styled from "styled-components/native" | |
| import {RNHoleView, RNHole, ERNHoleViewTimingFunction} from "react-native-hole-view"; | |
| import { BackHandler, TouchableWithoutFeedback } from "react-native"; | |
| import { height, isAndroid, isIOS, width } from "../Constants"; | |
| import { DescriptionPosition, HoleInfo } from "../Contexts/TutorialContext/types"; | |
| import { delay } from "../utils/Delay"; | |
| import { useTranslation } from "react-i18next"; | |
| import { useLocalize } from "../Contexts/DataContext"; | |
| const Container = styled.View` | |
| position: absolute; | |
| width: ${width}px; | |
| height: ${height}px; | |
| ` | |
| const HoleView = styled(RNHoleView)` | |
| position: absolute; | |
| width: ${width}px; | |
| height: ${height}px; | |
| justify-content: center; | |
| align-items: center; | |
| ` | |
| const Description = styled.Text` | |
| position: absolute; | |
| color: #ffffff; | |
| font-weight: bold; | |
| ` | |
| type TutorialProps = { | |
| holes: HoleInfo[]; | |
| onLastPress: () => void; | |
| whiteMode?: boolean | |
| }; | |
| export default ({holes: holesProp, onLastPress, whiteMode}: TutorialProps) => { | |
| const { t } = useTranslation(); | |
| const {languageTag} = useLocalize(); | |
| const index = useRef(0); | |
| const {current: lastIndex} = useRef(holesProp.length - 1); | |
| const {current: firstHole} = useRef<RNHole>({x: holesProp[0].holePosition.x, y: holesProp[0].holePosition.y, width: 0, height: 0, borderRadius: 0}) | |
| const touchBlock = useRef(false); | |
| const [holes, setHoles] = useState<RNHole[]>([firstHole]); | |
| const [descriptionText, setDescriptionText] = useState(''); | |
| const [descriptionPosition, setDescriptionPosition] = useState<DescriptionPosition>({ | |
| top: undefined, | |
| right: undefined, | |
| bottom: undefined, | |
| left: undefined, | |
| }); | |
| const replaceHole = useCallback(async (hole: HoleInfo) => { | |
| const {holePosition: {x, y, size, borderRadius}, descriptionPosition, descriptionText} = hole; | |
| setDescriptionText(t(descriptionText)); | |
| setDescriptionPosition(descriptionPosition); | |
| if(isIOS) { | |
| await delay(25); | |
| } | |
| setHoles([{x, y, width: size, height: size, borderRadius: borderRadius !== undefined ? borderRadius : size/2}]); | |
| }, []); | |
| useEffect(() => { | |
| setTimeout(() => { | |
| replaceHole(holesProp[0]); | |
| }, 250); | |
| return () => {} | |
| }, []); | |
| const onPress = useCallback(async () => { | |
| if(touchBlock.current) { | |
| return | |
| } | |
| touchBlock.current = true; | |
| if(index.current !== lastIndex) { | |
| const prevHole = holesProp[index.current]; | |
| index.current++; | |
| if(isAndroid) { | |
| const holeInfo = holesProp[index.current]; | |
| if(holeInfo.holePosition.size < prevHole.holePosition.size) { | |
| holeInfo.holePosition.borderRadius = prevHole.holePosition.size/2; | |
| } | |
| replaceHole(holesProp[index.current]); | |
| } | |
| else { | |
| replaceHole(holesProp[index.current]); | |
| } | |
| } | |
| else { | |
| const {holePosition} = holesProp[lastIndex]; | |
| replaceHole({ | |
| holePosition: { ...holePosition, size: 0, borderRadius: isAndroid ? holePosition.size/2 : undefined}, | |
| descriptionPosition: {}, | |
| descriptionText: '' | |
| }) | |
| await delay(250); | |
| onLastPress(); | |
| } | |
| setTimeout(() => { | |
| touchBlock.current = false; | |
| }, 500) | |
| }, []); | |
| useEffect(() => { | |
| if(isIOS) { | |
| return | |
| } | |
| const hardwareBackPress = (): boolean => { | |
| return true // 안드로이드 뒤로가기 실행 금지 | |
| } | |
| const backHandler = BackHandler.addEventListener('hardwareBackPress', hardwareBackPress) | |
| return () => { | |
| if(isIOS) { | |
| return | |
| } | |
| backHandler.remove() | |
| } | |
| }, []) | |
| return ( | |
| <TouchableWithoutFeedback onPress={onPress}> | |
| <Container> | |
| <HoleView | |
| animation={{timingFunction: ERNHoleViewTimingFunction.EASE_OUT, duration: 200}} | |
| holes={holes} | |
| style={{ | |
| backgroundColor: whiteMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.5)' | |
| }}> | |
| <Description | |
| style={{ | |
| ...descriptionPosition, | |
| textAlign: descriptionPosition.right === undefined ? 'left' : 'right', | |
| fontSize: languageTag === "ko" ? 20 : 18, | |
| color: whiteMode ? '#333333' : '#ffffff' | |
| }}> | |
| {descriptionText} | |
| </Description> | |
| </HoleView> | |
| </Container> | |
| </TouchableWithoutFeedback> | |
| ) | |
| } |
'리액트 네이티브' 카테고리의 다른 글
| RN Perspective Crop - OpenCV편 (0) | 2021.05.17 |
|---|---|
| 리액트 네이티브 튜토리얼 기능 구현3 - Example (0) | 2021.03.21 |
| 리액트 네이티브 튜토리얼 기능 구현1 - Context, Libraries (0) | 2021.03.21 |
| 리액트 네이티브 앱의 소스 코드 보호 (0) | 2020.12.17 |
| [인스타그램 클론코딩] 댓글 UI 구현3 - 원하는 컴포넌트 위치로 스크롤 (0) | 2020.06.04 |