애니메이션
애니메이션은 훌륭한 사용자 경험을 만들기 위해 매우 중요하다. 정지된 물체는 움직이기 시작할 때 관성을 극복해야 한다. 움직이는 물체는 모멘텀을 가지고 있으며 즉시 멈추는 경우는 드물다. 애니메이션은 인터페이스에서 물리적으로 믿을 수 있는 움직임을 전달할 수 있게 해준다.
React Native는 두 가지 상호보완적인 애니메이션 시스템을 제공한다: 특정 값에 대한 세밀하고 상호작용 가능한 제어를 위한 Animated
와 전역 레이아웃 트랜잭션을 애니메이션화하기 위한 LayoutAnimation
이다.
Animated
API
Animated
API는 다양한 흥미로운 애니메이션과 상호작용 패턴을 성능 저하 없이 간결하게 표현할 수 있도록 설계되었다. Animated
는 입력과 출력 간의 선언적 관계에 초점을 맞추고, 그 사이에 설정 가능한 변환을 적용하며, 시간 기반 애니메이션 실행을 제어하는 start
/stop
메서드를 제공한다.
Animated
는 여섯 가지 애니메이션 가능한 컴포넌트 타입을 제공한다: View
, Text
, Image
, ScrollView
, FlatList
, SectionList
. 또한 Animated.createAnimatedComponent()
를 사용해 커스텀 컴포넌트를 만들 수도 있다.
예를 들어, 마운트될 때 페이드 인 효과를 적용하는 컨테이너 뷰는 다음과 같이 작성할 수 있다:
- TypeScript
- JavaScript
여기서 어떤 일이 일어나는지 자세히 살펴보자. FadeInView
렌더링 메서드에서 useRef
를 사용해 fadeAnim
이라는 새로운 Animated.Value
를 초기화한다. View
의 opacity
속성은 이 애니메이션 값에 연결된다. 내부적으로 숫자 값이 추출되어 투명도를 설정하는 데 사용된다.
컴포넌트가 마운트되면 투명도는 0으로 설정된다. 그런 다음, fadeAnim
애니메이션 값에 이징 애니메이션이 시작되고, 값이 최종 값인 1로 애니메이션되면서 각 프레임마다 연결된 모든 매핑(이 경우 투명도)을 업데이트한다.
이 과정은 setState
를 호출하고 리렌더링하는 것보다 빠르게 최적화된 방식으로 수행된다. 전체 구성이 선언적이기 때문에, 구성을 직렬화하고 애니메이션을 높은 우선순위 스레드에서 실행하는 추가 최적화를 구현할 수 있다.
애니메이션 설정하기
애니메이션은 다양한 설정이 가능하다. 커스텀 및 사전 정의된 easing 함수, 지연 시간, 지속 시간, 감쇠율, 스프링 상수 등을 애니메이션 타입에 맞게 조정할 수 있다.
Animated
는 여러 애니메이션 타입을 제공하며, 가장 일반적으로 사용되는 것은 Animated.timing()
이다. 이 함수는 다양한 사전 정의된 easing 함수를 사용해 시간에 따라 값을 애니메이션화할 수 있으며, 커스텀 easing 함수도 사용할 수 있다. easing 함수는 일반적으로 객체의 점진적인 가속과 감속을 표현하기 위해 애니메이션에 사용된다.
기본적으로 timing
은 easeInOut 곡선을 사용한다. 이 곡선은 점진적으로 가속하여 최고 속도에 도달한 후, 다시 점진적으로 감속하여 멈추는 효과를 준다. easing
매개변수를 전달해 다른 easing 함수를 지정할 수도 있다. 커스텀 duration
이나 애니메이션 시작 전 delay
도 설정할 수 있다.
예를 들어, 객체가 최종 위치로 이동하기 전에 약간 뒤로 물러나는 2초 길이의 애니메이션을 만들고 싶다면 다음과 같이 작성한다:
Animated.timing(this.state.xPosition, {
toValue: 100,
easing: Easing.back(),
duration: 2000,
useNativeDriver: true,
}).start();
내장 애니메이션에서 지원하는 모든 설정 매개변수에 대해 더 알아보려면 Animated
API 레퍼런스의 애니메이션 설정하기 섹션을 참고한다.
애니메이션 합성하기
애니메이션은 순차적으로 또는 병렬로 결합하여 실행할 수 있다. 순차 애니메이션은 이전 애니메이션이 끝난 직후에 바로 시작하거나, 지정된 지연 시간 후에 시작할 수 있다. Animated
API는 sequence()
와 delay()
같은 여러 메서드를 제공한다. 이 메서드들은 실행할 애니메이션 배열을 인자로 받고, 필요에 따라 자동으로 start()
와 stop()
을 호출한다.
예를 들어, 다음 애니메이션은 멈추고, 다시 튕기면서 동시에 회전한다:
Animated.sequence([
// 감속 후 튕기고 회전
Animated.decay(position, {
// 멈추기
velocity: {x: gestureState.vx, y: gestureState.vy}, // 제스처 해제 시 속도
deceleration: 0.997,
useNativeDriver: true,
}),
Animated.parallel([
// 감속 후 병렬로:
Animated.spring(position, {
toValue: {x: 0, y: 0}, // 시작점으로 돌아가기
useNativeDriver: true,
}),
Animated.timing(twirl, {
// 회전
toValue: 360,
useNativeDriver: true,
}),
]),
]).start(); // 시퀀스 그룹 시작
하나의 애니메이션이 중단되면 그룹 내 다른 모든 애니메이션도 중단된다. Animated.parallel
에는 이 동작을 비활성화할 수 있는 stopTogether
옵션이 있다. 이 옵션을 false
로 설정하면 된다.
Animated
API 레퍼런스의 애니메이션 합성 섹션에서 모든 합성 메서드 목록을 확인할 수 있다.
애니메이션 값 결합하기
두 개의 애니메이션 값을 더하거나, 곱하거나, 나누거나, 나머지 연산을 통해 결합하여 새로운 애니메이션 값을 만들 수 있다.
특정 상황에서는 애니메이션 값을 계산하기 위해 다른 애니메이션 값을 역으로 변환해야 할 때가 있다. 예를 들어, 스케일을 반전시키는 경우(2x -> 0.5x)가 있다:
const a = new Animated.Value(1);
const b = Animated.divide(1, a);
Animated.spring(a, {
toValue: 2,
useNativeDriver: true,
}).start();
보간법(Interpolation)
각 속성은 먼저 보간법을 통해 처리할 수 있다. 보간법은 입력 범위를 출력 범위로 매핑하며, 일반적으로 선형 보간을 사용하지만 이징 함수(Easing Function)도 지원한다. 기본적으로 주어진 범위를 넘어서도 곡선을 추정하지만, 출력 값을 고정하도록 설정할 수도 있다.
0에서 1까지의 범위를 0에서 100으로 변환하는 기본 매핑은 다음과 같다:
value.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
});
예를 들어, Animated.Value
를 0에서 1로 설정하고, 위치는 150px에서 0px로, 투명도는 0에서 1로 애니메이션하고 싶을 수 있다. 이는 위 예제의 style
을 다음과 같이 수정하여 구현할 수 있다:
style={{
opacity: this.state.fadeAnim, // 직접 연결
transform: [{
translateY: this.state.fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [150, 0] // 0 : 150, 0.5 : 75, 1 : 0
}),
}],
}}
interpolate()
는 여러 범위 세그먼트도 지원하며, 데드 존(Dead Zone)을 정의하거나 다른 유용한 트릭을 구현할 때 편리하다. 예를 들어, -300에서 부정 관계를 설정하고, -100에서 0으로, 0에서 다시 1로, 100에서 0으로, 그리고 그 이후에는 데드 존으로 유지하려면 다음과 같이 할 수 있다:
value.interpolate({
inputRange: [-300, -100, 0, 100, 101],
outputRange: [300, 0, 1, 0, 0],
});
이 경우 매핑은 다음과 같다:
Input | Output
------|-------
-400| 450
-300| 300
-200| 150
-100| 0
-50| 0.5
0| 1
50| 0.5
100| 0
101| 0
200| 0
interpolate()
는 문자열 매핑도 지원하므로, 색상이나 단위가 포함된 값을 애니메이션할 수 있다. 예를 들어, 회전을 애니메이션하고 싶다면 다음과 같이 할 수 있다:
value.interpolate({
inputRange: [0, 360],
outputRange: ['0deg', '360deg'],
});
interpolate()
는 임의의 이징 함수도 지원하며, 이미 Easing
모듈에 구현된 많은 함수를 사용할 수 있다. 또한 interpolate()
는 outputRange
를 추정하는 동작을 설정할 수 있다. extrapolate
, extrapolateLeft
, 또는 extrapolateRight
옵션을 설정하여 추정 방식을 지정할 수 있다. 기본값은 extend
이지만, clamp
를 사용하여 출력 값이 outputRange
를 초과하지 않도록 할 수도 있다.
동적 값 추적하기
애니메이션 값은 일반 숫자 대신 다른 애니메이션 값을 toValue
로 설정해 다른 값을 추적할 수 있다. 예를 들어, Android의 Messenger에서 사용하는 "Chat Heads" 애니메이션은 다른 애니메이션 값에 고정된 spring()
을 사용하거나, timing()
과 duration
을 0으로 설정해 강력한 추적을 구현할 수 있다. 또한 보간(interpolation)과 함께 조합할 수도 있다:
Animated.spring(follower, {toValue: leader}).start();
Animated.timing(opacity, {
toValue: pan.x.interpolate({
inputRange: [0, 300],
outputRange: [1, 0],
}),
useNativeDriver: true,
}).start();
leader
와 follower
애니메이션 값은 Animated.ValueXY()
를 사용해 구현한다. ValueXY
는 팬(pannning)이나 드래그(dragging) 같은 2D 상호작용을 다루는 편리한 방법이다. 이는 두 개의 Animated.Value
인스턴스와 이를 호출하는 헬퍼 함수를 포함하는 기본 래퍼로, 많은 경우에서 Value
를 대체할 수 있다. 위 예제에서 x와 y 값을 모두 추적할 수 있게 해준다.
제스처 추적하기
패닝이나 스크롤링 같은 제스처와 이벤트는 Animated.event
를 사용해 애니메이션 값에 직접 매핑할 수 있다. 이는 구조화된 맵 문법을 통해 이루어지며, 복잡한 이벤트 객체에서 값을 추출할 수 있게 해준다. 첫 번째 레벨은 여러 인자를 매핑할 수 있도록 배열로 구성되며, 이 배열은 중첩된 객체를 포함한다.
예를 들어, 수평 스크롤 제스처를 다룰 때 event.nativeEvent.contentOffset.x
를 scrollX
(Animated.Value
)에 매핑하려면 다음과 같이 작성한다:
onScroll={Animated.event(
// scrollX = e.nativeEvent.contentOffset.x
[{nativeEvent: {
contentOffset: {
x: scrollX
}
}
}]
)}
다음 예제는 수평 스크롤 캐러셀을 구현하며, ScrollView
에서 사용된 Animated.event
를 통해 스크롤 위치 표시기가 애니메이션되도록 한다.
ScrollView와 Animated Event 예제
PanResponder
를 사용할 때는 gestureState.dx
와 gestureState.dy
에서 x와 y 위치를 추출할 수 있다. PanResponder
핸들러에 전달되는 두 번째 인자인 gestureState
에만 관심이 있기 때문에 배열의 첫 번째 위치에 null
을 사용한다.
onPanResponderMove={Animated.event(
[null, // 네이티브 이벤트는 무시
// gestureState에서 dx와 dy 추출
// 'pan.x = gestureState.dx, pan.y = gestureState.dy'와 같이 사용
{dx: pan.x, dy: pan.y}
])}
PanResponder와 Animated Event 예제
현재 애니메이션 값을 읽는 명확한 방법이 없다는 것을 눈치챘을 것이다. 이는 최적화로 인해 값이 네이티브 런타임에서만 알려질 수 있기 때문이다. 현재 값에 따라 JavaScript를 실행해야 한다면 두 가지 방법을 사용할 수 있다.
spring.stopAnimation(callback)
은 애니메이션을 중지하고 최종 값을callback
에 전달한다. 이 방법은 제스처 전환을 할 때 유용하다.spring.addListener(callback)
은 애니메이션이 실행되는 동안 비동기적으로callback
을 호출하며 최근 값을 제공한다. 이 방법은 상태 변경을 트리거할 때 유용하다. 예를 들어 사용자가 드래그할 때 특정 옵션에 밥블을 스냅하는 경우, 이러한 큰 상태 변경은 패닝과 같은 연속적인 제스처에 비해 몇 프레임의 지연에 덜 민감하기 때문이다.
Animated
는 애니메이션이 일반 JavaScript 이벤트 루프와 독립적으로 고성능으로 실행될 수 있도록 완전히 직렬화 가능하도록 설계되었다. 이는 API에 영향을 미치므로, 완전히 동기적인 시스템에 비해 약간 더 복잡해 보일 수 있다는 점을 염두에 두자. 이러한 제한 사항을 해결하기 위해 Animated.Value.addListener
를 사용할 수 있지만, 성능에 영향을 미칠 수 있으므로 신중하게 사용한다.
네이티브 드라이버 사용하기
Animated
API는 직렬화 가능하도록 설계되었다. 네이티브 드라이버를 사용하면 애니메이션을 시작하기 전에 모든 정보를 네이티브로 전송한다. 이렇게 하면 네이티브 코드가 UI 스레드에서 애니메이션을 수행할 수 있으며, 매 프레임마다 브리지를 거칠 필요가 없다. 애니메이션이 시작되면 JS 스레드가 블로킹되어도 애니메이션에 영향을 미치지 않는다.
일반 애니메이션에서 네이티브 드라이버를 사용하려면 애니메이션을 시작할 때 설정에서 useNativeDriver: true
로 설정하면 된다. useNativeDriver
속성이 없는 애니메이션은 레거시 이유로 기본값이 false로 설정되지만, 경고를 발생시키고 (TypeScript에서는 타입 체크 오류를 발생시킨다).
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- true로 설정
}).start();
애니메이션 값은 하나의 드라이버와만 호환된다. 따라서 애니메이션을 시작할 때 네이티브 드라이버를 사용한다면, 해당 값에 대한 모든 애니메이션도 네이티브 드라이버를 사용해야 한다.
네이티브 드라이버는 Animated.event
와도 함께 작동한다. 이는 특히 스크롤 위치를 따라가는 애니메이션에 유용하다. 네이티브 드라이버를 사용하지 않으면 React Native의 비동기 특성 때문에 애니메이션이 제스처보다 항상 한 프레임 뒤처지게 된다.
<Animated.ScrollView // <-- Animated ScrollView 래퍼 사용
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: {y: this.state.animatedValue},
},
},
],
{useNativeDriver: true}, // <-- true로 설정
)}>
{content}
</Animated.ScrollView>
네이티브 드라이버가 작동하는 모습을 보려면 RNTester 앱을 실행한 후 Native Animated 예제를 로드하면 된다. 또한 소스 코드를 살펴보면 이러한 예제가 어떻게 만들어졌는지 확인할 수 있다.
주의사항
Animated
로 할 수 있는 모든 기능이 네이티브 드라이버에서 지원되지는 않는다. 주요 제한 사항은 레이아웃 속성이 아닌 속성만 애니메이션할 수 있다는 점이다. 예를 들어 transform
과 opacity
는 작동하지만, 플렉스 박스와 위치 속성은 작동하지 않는다. Animated.event
를 사용할 때는 직접 이벤트만 지원되며, 버블링 이벤트는 지원되지 않는다. 이는 PanResponder
에서는 작동하지 않지만 ScrollView#onScroll
과 같은 이벤트에서는 작동한다는 의미다.
애니메이션이 실행 중일 때 VirtualizedList
컴포넌트가 더 많은 행을 렌더링하지 못할 수 있다. 사용자가 리스트를 스크롤하는 동안 길거나 반복적인 애니메이션을 실행해야 한다면, 애니메이션 설정에서 isInteraction: false
를 사용해 이 문제를 방지할 수 있다.
주의사항
rotateY
, rotateX
와 같은 변형 스타일을 사용할 때는 perspective
스타일을 함께 적용해야 한다. 이 설정이 없으면 일부 애니메이션이 Android에서 제대로 렌더링되지 않을 수 있다. 아래 예제를 참고하자.
<Animated.View
style={{
transform: [
{scale: this.state.scale},
{rotateY: this.state.rotateY},
{perspective: 1000}, // 이 줄이 없으면 iOS에서는 잘 작동하지만 Android에서는 애니메이션이 렌더링되지 않는다
],
}}
/>
RNTester 앱에는 Animated
를 사용한 다양한 예제가 포함되어 있다:
LayoutAnimation
API
LayoutAnimation
은 다음 리렌더링/레이아웃 주기에서 모든 뷰에 적용될 create
및 update
애니메이션을 전역적으로 설정할 수 있게 해준다. 이는 플렉스 박스 레이아웃 업데이트를 할 때 특정 속성을 직접 측정하거나 계산하지 않고도 애니메이션을 적용할 수 있어 유용하다. 특히 레이아웃 변경이 상위 요소에 영향을 미치는 경우, 예를 들어 "더 보기" 확장으로 인해 부모 요소의 크기가 증가하고 아래 행이 밀려나는 경우, 모든 컴포넌트를 동기화하여 애니메이션을 적용하려면 명시적인 조정이 필요하지만 LayoutAnimation
을 사용하면 이를 쉽게 처리할 수 있다.
LayoutAnimation
은 매우 강력하고 유용하지만, Animated
나 다른 애니메이션 라이브러리보다 제어 기능이 제한적이다. 따라서 원하는 동작을 LayoutAnimation
으로 구현할 수 없다면 다른 방식을 고려해야 한다.
Android에서 이 기능을 사용하려면 UIManager
를 통해 다음 플래그를 설정해야 한다:
UIManager.setLayoutAnimationEnabledExperimental(true);
이 예제는 미리 설정된 값을 사용한다. 필요에 따라 애니메이션을 커스터마이즈할 수 있으며, 더 많은 정보는 LayoutAnimation.js에서 확인할 수 있다.
추가 설명
requestAnimationFrame
requestAnimationFrame
은 브라우저에서 제공하는 폴리필로, 많은 개발자들이 이미 익숙할 것이다. 이 함수는 하나의 인자로 함수를 받아, 다음 리페인트 전에 그 함수를 호출한다. 자바스크립트 기반 애니메이션 API의 핵심 구성 요소로, 모든 애니메이션의 기반이 된다. 일반적으로 이 함수를 직접 호출할 필요는 없다. 애니메이션 API가 프레임 업데이트를 자동으로 관리하기 때문이다.
setNativeProps
직접 조작(Direct Manipulation) 섹션에서 언급했듯이, setNativeProps
를 사용하면 setState
를 호출하거나 컴포넌트 계층 구조를 리렌더링하지 않고도 네이티브 기반 컴포넌트(합성 컴포넌트와 달리 실제로 네이티브 뷰로 지원되는 컴포넌트)의 속성을 직접 수정할 수 있다.
Rebound 예제에서 이 기능을 사용해 스케일을 업데이트할 수 있다. 업데이트하려는 컴포넌트가 깊게 중첩되어 있고 shouldComponentUpdate
로 최적화되지 않은 경우에 특히 유용하다.
애니메이션이 프레임 드랍(초당 60프레임 미만)을 겪는 경우, setNativeProps
나 shouldComponentUpdate
를 사용해 최적화할 수 있다. 또는 useNativeDriver 옵션을 활용해 애니메이션을 JavaScript 스레드가 아닌 UI 스레드에서 실행할 수도 있다. 또한 InteractionManager를 사용해 애니메이션이 완료된 후에 계산 집약적인 작업을 지연시킬 수도 있다. In-App Dev Menu의 "FPS Monitor" 도구를 사용해 프레임 속도를 모니터링할 수 있다.