elevne's Study Note
RN E-Commerce App Clone: Animated, Moti 본문
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;
useRef 는 Ref 오브젝트를 하나 반환한다. (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.x 를 scorllX(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 값의 배수에서 중지된다. 일반적으로 snapToAlignment 및 decelaration="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 |