일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- React Native
- PanResponder
- react-naive-gesture-handler
- Animated
- Reanimated
- React-native
- Android
- IOS
- React
- Today
- Total
개발자포럼
Reanimated & React-Native-Gesture-Handler 본문
리액트 네이티브에서 애니메이션 효과와 제스처 기능을 구현하기 위해서는
React-native 모듈에 빌트인 되어있는 Animated와 PanResponder로 구현 가능하다
하지만 아래 이미지를 보면
실제로 구현되는 애니메이션에는 UI 스레드와 JS 스레드가 혼합되어 동작하고 있으며
JS 스레드에 사용이 많아질수록 퍼포먼스가 떨어지는 문제가 발생하게 된다.
이유는 Animated와 PanResponder에 사용하는 애니메이션이 네이티브에서 작동하는 것이 아닌 JS로 인해 작동하기 때문이다.
물론 이 문제를 해결하기 위해 Animated에서 useNativeDriver를 true로 설정해 네이티브에서 작동하는 것이 가능하지만
위치나 크기 같은 단순 애니메이션에서만 동작하기 때문에 완전히 해결하지 못한다
그래서 이 문제를 해결하기 위해 Reanimtaed와 react-native-gesture-handler를 사용해 보통 애니메이션 구현한다.
이 모듈들은 네이티브에서 동작하는 애니메이션인만큼 퍼포먼스면에서 기본 Animated보다 우수하다
다만 해당 모듈은 네이티브에서 만들어진 모듈이기때문에 react native link가 필요하다 따라서 expo가 아닌 react-native-cli에서 작동하는 것이 바람직하다. (React Native v0.60 부터 auto-linking을 제공하므로 link 안하셔도 됩니다.)
실제로 리액트 네이티브에서 네비게이션 구현을 위해 사용하는 React-Navigation 모듈은 Reanimated와 react-native-gesture-handler으로 구현되어있어 해당 모듈을 반드시 설치되어야 실행이 가능하도록 의존되어있다.
Reanimated는 Animated와 흡사하게 사용할수있으므로 Reanimated를 사용하기 위해서는 Animated를 먼저 알고있는것이 중요하다.
해당 모듈을 이용해 다음 스와이프 애니메이션을 구현해보자
해당 애니메이션을 구현하기 위해서는
- 제스처핸들러를 해당 animated.view 안에 감싸고 있어야한다.
- 스와이프가 일정 이상 되어있을경우 해당 아이템은 삭제되어야한다.
- 삭제된 아이템이 있던 범위를 채우기위한 애니메이션이 필요하다.
코드를 짜기전에
모든 제스처 핸들러는 해당 State를 가질수 있다.
BEGAN — 제스처가 시작되었을때
ACTIVE — 제스처가 현재 진행중일때
END — 제스처가 완료되었을때
CANCELLED/FAILED— 제스처가 외부적인 요인으로 인해 종료되었을때
UNKNOWN — 아직 제스처를 하지 않았을때
제스처 핸들러에 해당 props를 이용해 이벤트를 받을수 있다.
onHandlerStateChange: 사용자가 제스처를 시작하거나 종료하는 경우와 같이 State가 변경될때 동작한다.
onGestureEvent: 제스처를 하고있을때 제스처가 발생되는 위치, 손가락이 움직이는 속도 등에 대한 데이터를 제공한다.
Reanimated 에서는 if / else 대신에 condition에 약자인 cond() 를 사용하며
대입을 하기 위해서는 = 대신에 set()를 사용한다.
// JavaScript ------------------------------------------
let val = 0
const checkOne = true
const checkTwo = false
// If checkOne and checkTwo are both truthy, set val to 2
// otherwise set val to 3
if (checkOne && checkTwo) val = 2
else val = 3
// Reanimated ------------------------------------------
import Animated from 'react-native-reanimated'
const {
cond,
set,
Value
} = Animated
const val = new Value(0)
// In reanimated 1 is a "truthy" value and 0 is "falsy"
const checkOne = new Value(1)
const checkTwo = new Value(0)
// If checkOne and checkTwo are both truthy, set val to 2
// otherwise set val to 3
cond(and(checkOne, checkTwo), set(val, 2), set(val, 3))
//eq() - equal, 즉 비교함수 checkOne, checkTwo가 같으면 true 다르면 false
//삼항 연산자와 흡사하다고 생각하시면 됩니다.
이벤트가 발생시에 콜백으로 이벤트를 처리할수있지만
animated에서 제공하는 event 함수를 사용하면 손쉽게 이벤트 데이터를 value에 저장 할 수있다.
nativeEvnet 객체 안에있는 translationX는 첫 터치순간부터 손가락이 움직인 순간까지의 진행된 X 값을 가지고 있으며 해당 X값을 animState.position이라는 Value에 저장시킨후 해당 value 를 스타일에 적용시킨다.
그렇게되면은 좌우로 움직이는 애니메이션을 구현할수있다.
// Update our animated translation value to follow finger
onPanEvent = event([{
nativeEvent: ({ translationX }) => block([
set(this.animState.position, translationX)
])
}])
...
// Attach animated value to our View
<Animated.View
style={{
transform: [{ translateX: this.animState.position }]
}}
/>
translationX가 일정 범위를 넘어갈경우에는 해당 아이템이 삭제되도록 구현해야하므로 cond를 사용해 구현하도록한다
lessThan은 첫번째 인수가 두번째 인수보다 작으면 true 크면 false를 반환한다 (반대는 greaterThan)
true 일경우 props.onSwipre를 수행해 상위 컴포넌트에서 해당 item을 삭제하는 콜백을 수행해야하는데
그럴때는 call() 함수를 이용한다 첫번째인수로는 업데이트되는 value값이고 그 값이 변경되었을경우 두번째 인수인 콜백을 수행한다.
// If translationX is less than our swipeThreshold
// call our onSwipe callback.
cond(
lessThan(translationX, swipeThreshold),
call([position], () => this.props.onSwipe(this.props.item)
)
)
만약에 해당 값보다 덜 스와이프 한다면은 다시 돌아가는 애니메이션도 있어야한다.
그러기 위해서는 timing이나 spring을 사용해서 구현해야하는데 그러기 위해서는 Clock도 필요하다
import Animated from "react-native-reanimated"
const { clock, clockRunning, spring, Value } = Animated
const clock = new Clock()
// Determines how "springy" row is when it
// snaps back into place after released
const animConfig = {
toValue: new Value(0),
damping: 20,
mass: 0.2,
stiffness: 100,
overshootClamping: false,
restSpeedThreshold: 0.2,
restDisplacementThreshold: 0.2,
};
// Stores data about the current state of an animation
const animState = {
finished: new Value(0),
position: new Value(0),
velocity: new Value(0),
time: new Value(0),
};
// Calculate next tick in animation
spring(clock, animState, animConfig)
스와이프가 덜 된다면은 제스처 핸들러가 END 상태로 들어가고 clock를 시작시킨다 그러면 스프링 애니메이션을 사용하여 행 번역이 다시 0으로 재설정됨:
// If the user has ended the gesture AND the clock is not running,
// start clock.
cond(
and(
eq(state, GestureState.END),
not(clockRunning(this.clock)
)
), startClock(this.clock))
실습
하나의 터치로 드래그를 구현하기 위해 PanGestureHandler를 사용
컴포넌트 안에 움직이고싶은 뷰 컴포넌트를 넣는다
const {width} = Dimensions.get('window');
const currState = new Value(State.UNDETERMINED);
const value = new Value(0);
const clock = new Clock();
const overValue = new Value(width * 0.4);
const overMinusValue = new Value(width * -0.4)
<PanGestureHandler
onHandlerStateChange={event([
{nativeEvent: ({state}) => set(currState, state)},
])}
onGestureEvent={onGestureEvent}>
<Animated.View
style={[
styles.item, // item: {height: 80, backgroundColor: 'tomato'}
{transform: [{translateX: value}]},
]} />
</PanGestureHandler>
onHandlerStateChange 에는 제스처에 상태에 대한 값을 저장시키는 event 함수를 넣는다
onGestureEvent 에는 현재 제스처 상태, 드래그 되어진 범위만큼 다른 애니메이션을 구현해야한다.
function runSpring(clock) {
const state = {
finished: new Value(0),
velocity: new Value(0),
position: new Value(0),
time: new Value(0),
};
const config = {
damping: 30,
mass: 1,
stiffness: 121.6,
overshootClamping: false,
restSpeedThreshold: 0.001,
restDisplacementThreshold: 0.001,
toValue: new Value(0),
};
return [
cond(clockRunning(clock), 0, [
set(state.finished, 0),
set(state.velocity, 0),
set(state.position, value),
set(config.toValue, 0),
startClock(clock),
]),
spring(clock, state, config),
cond(state.finished, stopClock(clock)),
set(value, state.position),
];
}
const onGestureEvent = event([
{
nativeEvent: ({translationX}) =>
cond(
eq(currState, State.ACTIVE), // 1. if 현재 드래그 상태가 ACTIVE인가?
set(value, translationX), // 1-1. true 드래그 되어진 X값을 value에 저장
cond( // 1-2. false ACTIVE가 아니라면
eq(currState, State.END), // 2. if 현재 상태가 드래그를 마친 상태인가?
cond( // 2-1. true if 드래그를 마친 상태에서
or( // 3. if value가 overValue보다 높거나 overMinusValue 보다 낮으면
greaterThan(value, overValue),
lessThan(value, overMinusValue),
),
call([], () => this.props.onSwipe() //3-1. true props.onSwipe를 부른다
runSpring(clock), // 3-2. false 원래 위치로 돌아가는 스프링효과를 준다.
),
stopClock(clock), //2-2. 아무것도 아니라면은 Clock을 멈춰라
),
),
},
]);
삭제된 아이템이 있던 범위를 채우기위한 애니메이션은
기본적으로 제공하는 LayoutAnimation 을 사용하면 금방 구현가능하다
if (Platform.OS === 'android') { // 안드로이드는 이 설정을 꼭 해줘야한다.
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
deleteItem = (item) => {
const updatedData = this.state.data.filter(d => d !== item)
// Animate list to close gap when item is deleted
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring)
this.setState({ data: updatedData })
완성!
FULL CODE
import React, {useState, useEffect} from 'react';
import {
StyleSheet,
View,
Dimensions,
UIManager,
LayoutAnimation,
Text,
} from 'react-native';
import {PanGestureHandler, State} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
const Wrap = () => {
const [list, setList] = useState([1, 2, 3, 4, 5, 6, 7]);
useEffect(() => UIManager.setLayoutAnimationEnabledExperimental(true), []);
const deleteItem = (item) => {
const updatedList = list.filter((value) => item !== value);
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
setList(updatedList);
};
return (
<View styles={styles.container}>
{list.map((value) => (
<Test id={value} key={value} onSwipe={deleteItem} />
))}
</View>
);
};
const {
Value,
event,
set,
cond,
eq,
Clock,
clockRunning,
startClock,
stopClock,
spring,
lessThan,
greaterThan,
or,
call,
} = Animated;
const {width} = Dimensions.get('window');
const Test = (props) => {
const currState = new Value(State.UNDETERMINED);
const value = new Value(0);
const clock = new Clock();
const overValue = new Value(width * 0.4);
const overMinusValue = new Value(width * -0.4);
function runSpring(clock) {
const state = {
finished: new Value(0),
velocity: new Value(0),
position: new Value(0),
time: new Value(0),
};
const config = {
damping: 30,
mass: 1,
stiffness: 121.6,
overshootClamping: false,
restSpeedThreshold: 0.001,
restDisplacementThreshold: 0.001,
toValue: new Value(0),
};
return [
cond(clockRunning(clock), 0, [
set(state.finished, 0),
set(state.velocity, 0),
set(state.position, value),
set(config.toValue, 0),
startClock(clock),
]),
spring(clock, state, config),
cond(state.finished, stopClock(clock)),
set(value, state.position),
];
}
const onGestureEvent = event([
{
nativeEvent: ({translationX}) =>
cond(
eq(currState, State.ACTIVE),
set(value, translationX),
cond(
eq(currState, State.END),
cond(
or(
greaterThan(value, overValue),
lessThan(value, overMinusValue),
),
call([], () => props.onSwipe(props.id)), // call 넣을곳
runSpring(clock),
),
stopClock(clock),
),
),
},
]);
return (
<View style={{position: 'relative'}}>
<PanGestureHandler
onHandlerStateChange={event([
{nativeEvent: ({state}) => set(currState, state)},
])}
onGestureEvent={onGestureEvent}>
<Animated.View
style={[styles.item, {transform: [{translateX: value}]}]}>
<Text>{props.id}</Text>
</Animated.View>
</PanGestureHandler>
<View
style={{
position: 'absolute',
top: 0,
height: 80,
width: width,
zIndex: -1,
backgroundColor: 'green',
}}></View>
</View>
);
};
export default Wrap;
const styles = StyleSheet.create({
container: {flex: 1},
item: {
height: 80,
backgroundColor: 'tomato',
borderRadius: 1,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
더 많은 Reanimated 정보는 이곳에서 확인 가능하다
https://software-mansion.github.io/react-native-reanimated/
React Native Reanimated
software-mansion.github.io
이미지 참고 https://codeburst.io/react-native-gestures-animations-you-only-have-16ms-to-render-everything-598fea094e99
참고 문서 https://medium.com/async-la/swipe-to-delete-with-reanimated-react-native-gesture-handler-bd7d66085aee