elevne's Study Note

RN E-Commerce App Clone: Animated, Moti 본문

Frontend/React Native

RN E-Commerce App Clone: Animated, Moti

elevne 2023. 6. 26. 19:46

React Native 활용 앱 개발을 연습하기 위해 아래 링크의 E-commerce App Clone 을 진행해보았다.

 

링크: https://www.youtube.com/watch?v=cd4v2T-_RE0&list=LL&index=2 

 

 

우선 첫 번째 시간에는 Walkthrough 스크린에 대한 작업을 진행해주었다. Walkthrough 가 여러 개의 페이지로 구성될 수 있게끔 Animated.FlatList 컴포넌트를 활용하여 작성한다. 우선 React Native Animation 복습을 진행했다.

 

 

 

Animation 을 사용하면 오브젝트를 이동하거나, 모양이나 색상을 변경하여 실제 상호작용을 모방할 수 있다. Animated 를 사용하기 위해서는 Animated.Value 를 하나 만들어야한다. Value 를 만들 때에는 useRef 를 사용한다.

 

 

const scrollX = React.useRef(new Animated.Value(0)).current;

 

 

useRefRef 오브젝트를 하나 반환한다. (Ref 오브젝트는 { current : value } 형태를 가진다) useRef 함수 내에 넣는 값은 초기값으로 설정하게 되는 값이다. Ref 오브젝트의 currnet 값은 ref.current = value; 와 같은 형태로 언제든 변경해줄 수 있다. 이 Ref 는 컴포넌트의 전 생애주기동안 유지되며, 언마운트되기 전가지 계속 사용할 수 있는 값이다.

 

 

 

Animation 에서 제스처(이동, 스크롤 등) 및 기타이벤트에 대한 추적은 Animated.event 를 사용한다. Animated.event 를 사용하면 제스처를 애니메이션 값에 직접 매핑할 수 있다. 이 작업은 복잡한 이벤트 개체에서 값을 추출할 수 있도록 구조화된 맵 구문을 사용하여 수행된다고 한다.

 

 

onScroll={Animated.event(
    [{ nativeEvent: { contentOffset: { x: scrollX }}}],
    {
        useNativeDriver: false
    }
)}

 

 

위 코드는 수평 스크롤 제스처로 작업할 때 event.nativeEvent.contentOffset.xscorllX(Animated.Value) 로 매핑해준다.

 

 

 

Animated.FlatList 는 아래와 같이 작성되었다.

 

 

return (
    <View
        style={{
            flex: 1, backgroundColor: COLORS.light
        }}
    >
        <Animated.FlatList data={constants.walkthrough}
                           keyExtractor={(item) => item.id}
                           horizontal
                           snapToInterval={SIZES.width}
                           decelerationRate="fast"
                           showsHorizontalScrollIndicator={false}
                           scrollEventThrottle={16}
                           onViewableItemsChanged={onViewChangeRef.current}
                           onScroll={Animated.event(
                               [{ nativeEvent: { contentOffset: { x: scrollX }}}],
                               {
                                   useNativeDriver: false
                               }
                           )}
                           renderItem={({item, index}) => {
                               return (
                                   <View
                                       style={{
                                           width: SIZES.width,
                                           justifyContent: "center"
                                       }}>
                                       {/*Walkthrough Images*/}
                                       <View
                                       style={{
                                           flex:1,
                                           justifyContent: "center"
                                       }}>
                                           {index == 0 && <Walkthrough1 />}
                                           {index == 1 && <Walkthrough2 animate={walkthrough2Animate}/>}
                                       </View>
                                       {/*Title & Description*/}
                                       <View
                                       style={{
                                           height: SIZES.height * 0.35,
                                           alignItems: "center",
                                           justifyContent: "flex-start",
                                           paddingHorizontal: SIZES.padding
                                       }}
                                       >
                                           <Text
                                           style={{
                                               ...FONTS.h1
                                           }}
                                           >
                                               {item.title}
                                           </Text>
                                           <Text
                                           style={{
                                               marginTop: SIZES.radius,
                                               textAlign: "center",
                                               ...FONTS.body3,
                                               color: COLORS.grey
                                           }}
                                           >
                                               {item.sub_title}
                                           </Text>
                                       </View>
                                   </View>
                               )
                           }}
                           />
        {renderFooter()}
    </View>
)

 

 

 

우선 모든 FlatList 의 엘리먼트에 공통으로 쓰인 Footer (renderFooter()) 부분부터 살펴본다. Footer 에는 현재 스크린의 위치를 알려주는 Dots 컴포넌트가 사용되었다.

 

 

const Dots = () => {
    const dotPosition = Animated.divide(scrollX, SIZES.width)
    return (
        <View
        style={{
            flexDirection: "row",
            alignItems: "center",
            justifyContent: "center"
        }}
        >
            {
                constants.walkthrough.map((item, index) => {
                    const dotColor = dotPosition.interpolate({
                        inputRange: [index - 1, index, index + 1],
                        outputRange: [COLORS.dark08, COLORS.primary, COLORS.dark08],
                        extrapolate: "clamp"
                    })
                    return (
                        <Animated.View
                            key={`dot-${index}`}
                            style={{
                                borderRadius: 5,
                                marginHorizontal: 6,
                                width: 10,
                                height: 10,
                                backgroundColor: dotColor
                            }}
                        />
                    )
                })
            }
        </View>
    )
}

 

 

위에서는 Animated.divide 메소드가 사용되었다. Animated.divide 는 말그대로 두 개의 값을 사용하여 나눗셈을 진행하여 새로운 Animated.Value 를 리턴한다. 이 값은 몇 번째 Dot 이 활성화되어야 하는지를 지정하는데에 사용된다. (이 때, divide 에 사용된 SIZES.width 값은 Dimensions.get("window").width 값이다. Dimensions 는 React Native 에서 사용자의 스크린 크기를 가져오는데 사용된다)

 

 

그 다음으로는 interpolate 메소드가 사용되었다. 이는 Animated.Value 의 변화에 따라 종속적으로 값이 변하도록 만드는 기능을 제공한다. 여러가지 애니메이션을 만들고 병렬적으로 실행할 필요 없이, 이를 활용하여 하나의 Animated.Value 만으로 여러 값을 통제할 수 있다고 한다. Interpolate 는 값이 어떠한 수치를 가질 때 (inputRange) 어떤 값을 리턴할지 (outputRange) 를 지정할 수 있다.

 

 

또, 밑에 사용된 extrapolate 옵션에 대해서도 알아보아야 한다. extrapolate 는 Animated.Value 가 interpolate 의 inputRange 를 벗어났을 때 리턴하는 값을 처리하는 방법에 대한 설정이다. "extend", "identity" 혹은 "clamp" 로 설정할 수 있다. "extend" 는 extrapolate 의 기본값으로, Animated.Value 가 inputRange 를 벗어나도 현재의 매핑을 그대로 유지한다. "identity" 는 inputRange 를 벗어났을 때 mapping 에 관계없이 Animated.Value 값을 그대로 반환한다. 마지막으로 위에서 사용된 "clamp" 는 inputRange 안에서만 변화가 있게하고, inputRange 를 벗어나면 변화가 없게된다.

 

 

 

이를 사용하여 Footer 은 아래와 같이 전체코드가 작성되었다.

 

 

const renderFooter = () => {
    return (
        <View
        style={{
            position: "absolute",
            bottom: 0,
            left: 0,
            right: 0,
            height: SIZES.height * 0.2,
            alignItems: "center",
            justifyContent: "space-between",
            paddingHorizontal: SIZES.padding,
            paddingVertical: SIZES.height > 700 ? SIZES.padding : 20
        }}
        >
            <Dots />
            {/*Buttons*/}
            <View
                style={{
                    flexDirection: "row",
                    height: 55
                }}
            >
                <TextButton
                label="Join Now"
                contentContainerStyle={{
                    flex: 1,
                    borderRadius: SIZES.radius,
                    backgroundColor: COLORS.lightGrey
                }}
                labelStyle={{
                    color: COLORS.primary,
                    ...FONTS.h3
                }}
                />
                <TextButton
                    label="Log In"
                    contentContainerStyle={{
                        flex: 1,
                        marginLeft: SIZES.radius,
                        borderRadius: SIZES.radius,
                        backgroundColor: COLORS.primary
                    }}
                    labelStyle={{
                        ...FONTS.h3
                    }}
                />
            </View>
        </View>
    )
}

 

 

 

그 다음으로는 Animated.FlatList 를 다시 살펴본다. keyExtractor 은 사용되는 아이템에서 어떤 값을 key 로 사용할지 알려주는 역할을 한다. horizontal 을 옵션으로 넣게되면 옆으로 리스트를 넘길 수 있게된다. snapToInterval 옵션을 설정하면 스크롤 뷰가 snapToInterval 값의 배수에서 중지된다. 일반적으로 snapToAlignmentdecelaration="fast" 와 함께 사용한다. (decelarationRate 는 손가락의 움직임에 비해 얼마나 빨리 리스트가 넘어가는지 지정할 수 있다 ("fast", "slow" 등)) scrollEventThrottle 은 스크롤하는 동안 스크롤 이벤트가 발생하는 빈도 (ms 단위의 시간 간격) 를 제어한다. 숫자가 작을수록 스크롤 위치를 추적하는 코드의 정확도가 향상되지만 브리지를 통해 전송되는 정보의 양으로 인해 스크롤 성능 문제가 발생할 수 있다. JS 실행루프가 화면 새로고침 빈도에 동기화되므로 1-16 사이에 설정된 값 사이의 차이를 알 수 없다고 한다.

 

 

 

이제 Animated.FlatList 내에서 렌더링하는 Walkthrough1, 2 컴포넌트를 살펴봐야한다. 우선 Walkthrough1, 그 중에서도 이미지 리스트를 자동 스크롤시키는 코드를 살펴본다.

 

 

const Walktrough1 = () => {

    // ROW1
    const [row1Images, setRow1Images] = React.useState([
        ...constants.walkthrough_01_01_images,
        ...constants.walkthrough_01_01_images
    ])
    const [currentPosition, setCurrentPosition] = React.useState(0)
    // ROW2
    const [row2Images, setRow2Images] = React.useState([
        ...constants.walkthrough_01_02_images,
        ...constants.walkthrough_01_02_images
    ])
    const [row2CurrentPosition, setRow2CurrentPosition] = React.useState(0)
    // REF
    const row1FlatListRef = React.useRef()
    const row2FlatListRef = React.useRef()

    React.useEffect(
        () => {
            let positionTimer;

            const timer = () => {
                positionTimer = setTimeout(() => {
                    // INCREMENT SCROLL POSITION WITH EACH NEW INTERVAL

                    // SLIDER 1
                    setCurrentPosition(prevPosition => {
                        const position = Number(prevPosition) + 1;
                        row1FlatListRef?.current?.scrollToOffset({offset:position, animated:false})
                        const maxOffset = constants.walkthrough_01_01_images.length * ITEM_WIDTH
                        if (prevPosition > maxOffset) {
                            const offset = prevPosition - maxOffset;
                            row1FlatListRef?.current?.scrollToOffset(
                                {offset, animated:false}
                            )
                            return offset
                        } else {
                            return position
                        }
                    })

                    // SLIDER 2
                    setRow2CurrentPosition(prevPosition => {
                        const position = Number(prevPosition) + 1;
                        row2FlatListRef?.current?.scrollToOffset({offset:position, animated:false})
                        const maxOffset = constants.walkthrough_01_01_images.length * ITEM_WIDTH
                        if (prevPosition > maxOffset) {
                            const offset = prevPosition - maxOffset;
                            row2FlatListRef?.current?.scrollToOffset(
                                {offset, animated:false}
                            )
                            return offset
                        } else {
                            return position
                        }
                    })
                    timer();
                }, 32)
            }
            timer();
            return () => {
                clearTimeout(positionTimer)
            }
        }, []
    )
    ...

 

 

scrollToOffset 은 FlatList 를 특정 offset 으로 이동시킨다. (이를 위해 이후에 FlatList 컴포넌트를 작성할 때 ref 로 useRef() 오브젝트를 넣어준다) 이동시킬 때 최대길이를 넘어가게되면 다시 처음으로 돌아가게끔 한다. setTimeout 함수를 사용, 재귀적으로 함수를 호출해 계속해서 리스트가 움직이도록 한다.

 

 

 

...
return (
        <View>
            {/*Slider 1*/}
            <FlatList
                scrollEnabled={false}
                ref={row1FlatListRef}
                decelerationRate="fast"
                horizontal
                showsHorizontalScrollIndicator={false}
                listKey="Slider1"
                keyExtractor={(_, index) => `Slider1_${index}`}
                data={row1Images}
                renderItem={({item, index}) => {
                    return (
                        <View
                            style={{
                                width: ITEM_WIDTH,
                                alignItems: "center",
                                justifyContent: "center"
                            }}
                        >
                            <Image source={item}
                                   style={{
                                       width: 110,
                                       height: 110
                                   }} />
                        </View>
                    )
                }}/>
            {/*Slider 2*/}
            <FlatList
                scrollEnabled={false}
                ref={row2FlatListRef}
                decelerationRate="fast"
                style={{
                    marginTop: SIZES.padding,
                    transform: [{rotate: "180deg"}]
                }}
                horizontal
                showsHorizontalScrollIndicator={false}
                listKey="Slider2"
                keyExtractor={(_, index) => `Slider2_${index}`}
                data={row2Images}
                renderItem={({item, index}) => {
                    return (
                        <View
                            style={{
                                width: ITEM_WIDTH,
                                alignItems: "center",
                                justifyContent: "center",
                                transform: [{rotate:"180deg"}]
                            }}
                        >
                            <Image source={item}
                                   style={{
                                       width: 110,
                                       height: 110
                                   }} />
                        </View>
                    )
                }}/>
        </View>

    )
}

 

 

위 코드를 확인해보면 두 번째 FlatList, render View 의 style 에는 transform: [{rotate: "180deg"}] 가 적용되어 있는 것을 확인할 수 있다. 우선 FlatList 를 180 도 뒤집음으로써 반대 방향으로 이동하게끔 하고, 다시 View 에 이를 적용해 이미지를 원래 각도로 회전시킨다.

 

 

 

그 다음으로는 Walkthrough2 를 살펴본다. 두 번째 스크린에서는 처음 2 번 화면으로 넘어갈 때, 이미지에 애니메이션이 작동하도록 작성하였다. 이 때 moti 모듈을 사용하였다. React Native 에서 애니메이션을 사용할 때 활용할 수 있는 패키지다. MotiImage 컴포넌트로 이미지를 불러오고, 그 안에 state 값으로 useDynamicAnimation() 으로 만든 오브젝트를 넣어주어야한다. useDynamicAnimation 을 사용하여 우선 initial state 를 설정해주고, animateTo 메소드로 애니메이션을 처리한다.

 

 

처음 2 번 화면으로 넘어갈 때 딱 한 번만 이 애니메이션이 실행되도록 하기 위해, animation 이라는 props 를 받아서 사용하고 있다. 이 props 는 Walkthrough.js 에서 아래와 같이 생성하여 넘겨준다.

 

 

const [walkthrough2Animate, setWalkthrough2Animate] = React.useState(false)
const onViewChangeRef = React.useRef(({viewableItems, changed}) => {
    if (viewableItems[0].index == 1) {
        setWalkthrough2Animate(true)
    }
})

 

 

useRef 를 사용하고, Animated.FlatList 에 onViewableItemsChanged={onViewChangeRef.current} 로 넣어준다. (onViewableItemsChanged 는 이름에서 알 수 있듯이 FlatList 내에서 보이는 엘리먼트가 달라질 때 호출되는 것을 지정)

 

 

 

최종 화면은 아래와 같이 구성되었다.

 

 

 

 

 

 

 

 

 

 

 

 

 

Reference:

https://yuddomack.tistory.com/entry/7React-Native-Animated-%EC%82%AC%EC%9A%A9%EB%B2%95

'Frontend > React Native' 카테고리의 다른 글

React Native 로 To-Do App 만들기 (4)  (0) 2023.04.24
React Native 로 To-Do App 만들기 (3)  (0) 2023.04.23
React Native 로 To-Do App 만들기 (2)  (0) 2023.03.26
React Native: Redux (2)  (0) 2023.03.25
React Native: Redux  (0) 2023.03.24