애니메이션
애니메이션은 훌륭한 사용자 경험을 만드는 데 매우 중요하다. 정지된 물체는 움직이기 시작할 때 관성을 극복해야 한다. 움직이는 물체는 관성을 가지며 즉시 멈추는 경우가 드물다. 애니메이션을 통해 인터페이스에서 물리적으로 믿을 수 있는 움직임을 전달할 수 있다.
React Native는 두 가지 상호 보완적인 애니메이션 시스템을 제공한다. 특정 값을 세밀하고 인터랙티브하게 제어할 수 있는 Animated
와 전역 레이아웃 변화를 애니메이션으로 처리할 수 있는 LayoutAnimation
이다.
Animated
API
Animated
API는 다양한 애니메이션과 인터랙션 패턴을 성능 저하 없이 간결하게 표현할 수 있도록 설계되었다. Animated
는 입력과 출력 간의 선언적 관계에 초점을 맞추고, 그 사이에 구성 가능한 변환을 추가하며, 시간 기반 애니메이션 실행을 제어하는 start
/stop
메서드를 제공한다.
Animated
는 View
, Text
, Image
, ScrollView
, FlatList
, SectionList
와 같이 애니메이션을 적용할 수 있는 6가지 컴포넌트 타입을 제공한다. 또한 Animated.createAnimatedComponent()
를 사용해 직접 애니메이션 컴포넌트를 만들 수도 있다.
예를 들어, 마운트될 때 페이드 인 효과를 적용하는 컨테이너 뷰는 다음과 같이 구현할 수 있다:
- TypeScript
- JavaScript
이 코드가 어떻게 동작하는지 자세히 살펴보자. FadeInView
의 렌더링 메서드에서 useRef
를 사용해 fadeAnim
이라는 새로운 Animated.Value
를 초기화한다. View
의 opacity
속성은 이 애니메이션 값에 연결된다. 내부적으로 숫자 값이 추출되어 투명도를 설정하는 데 사용된다.
컴포넌트가 마운트되면 투명도는 0으로 설정된다. 그런 다음 fadeAnim
애니메이션 값에 이징 애니메이션이 시작되고, 이 값은 최종 값인 1로 애니메이션되면서 각 프레임마다 연결된 모든 매핑(이 경우에는 투명도만 해당)을 업데이트한다.
이 과정은 setState
를 호출하고 리렌더링하는 것보다 빠르게 최적화된 방식으로 수행된다. 전체 구성이 선언적이기 때문에, 구성을 직렬화하고 애니메이션을 고우선순위 스레드에서 실행하는 추가 최적화를 구현할 수 있다.
애니메이션 설정하기
애니메이션은 매우 세밀하게 설정할 수 있다. 커스텀 또는 사전 정의된 이징 함수, 딜레이, 지속 시간, 감쇠 계수, 스프링 상수 등 다양한 요소를 애니메이션 타입에 맞게 조정할 수 있다.
Animated
는 여러 애니메이션 타입을 제공하며, 가장 일반적으로 사용되는 것은 Animated.timing()
이다. 이 함수는 다양한 사전 정의된 이징 함수를 사용해 시간에 따라 값을 애니메이션화할 수 있으며, 커스텀 함수도 사용할 수 있다. 이징 함수는 일반적으로 물체의 점진적인 가속과 감속을 표현하기 위해 애니메이션에 사용된다.
기본적으로 timing
은 완전한 속도까지 점진적으로 가속하고, 멈출 때까지 점진적으로 감속하는 easeInOut 곡선을 사용한다. 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)
각 속성은 먼저 보간법을 통해 처리할 수 있다. 보간법은 입력 범위를 출력 범위에 매핑하며, 일반적으로 선형 보간을 사용하지만 이징 함수도 지원한다. 기본적으로 주어진 범위를 넘어서도 곡선을 외삽하지만, 출력 값을 고정할 수도 있다.
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()
는 여러 범위 세그먼트도 지원하며, 이는 데드 존이나 기타 유용한 트릭을 정의하는 데 편리하다. 예를 들어, -300에서 0으로, -100에서 1로, 0에서 0으로, 100에서 데드 존으로 유지되는 관계를 만들려면 다음과 같이 할 수 있다:
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
는 패닝(panning)이나 드래깅(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 이벤트 예제
PanResponder
를 사용할 때, gestureState.dx
와 gestureState.dy
에서 x와 y 위치를 추출하려면 다음 코드를 활용한다. 배열의 첫 번째 위치에 null
을 사용하는 이유는 PanResponder
핸들러에 전달되는 두 번째 인자인 gestureState
에만 관심이 있기 때문이다.
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
을 호출하며, 최근 값을 제공한다. 이 방법은 상태 변경을 트리거할 때 유용하다. 예를 들어, 사용자가 드래그할 때 특정 옵션에 가까워지면 해당 옵션으로 스냅하는 경우가 이에 해당한다. 이러한 큰 상태 변경은 패닝처럼 60fps로 실행되어야 하는 연속적인 제스처에 비해 몇 프레임의 지연에 덜 민감하기 때문이다.
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
와 같은 transform 스타일을 사용할 때는 perspective
스타일을 함께 적용해야 한다. 현재 이 설정이 없으면 일부 애니메이션이 안드로이드에서 제대로 렌더링되지 않을 수 있다. 아래 예제를 참고하자.
<Animated.View
style={{
transform: [
{scale: this.state.scale},
{rotateY: this.state.rotateY},
{perspective: 1000}, // 이 줄이 없으면 iOS에서는 잘 작동하지만 안드로이드에서는 애니메이션이 렌더링되지 않음
],
}}
/>
RNTester 앱에는 Animated
를 사용한 다양한 예제가 포함되어 있다:
LayoutAnimation
API
LayoutAnimation
은 다음 렌더링/레이아웃 사이클에서 모든 뷰에 적용될 create
및 update
애니메이션을 전역적으로 설정할 수 있게 해준다. 이 기능은 플렉스 박스 레이아웃 업데이트를 할 때 특정 속성을 직접 측정하거나 계산하지 않고도 애니메이션을 적용할 수 있어 유용하다. 특히 레이아웃 변경이 상위 요소에 영향을 미치는 경우, 예를 들어 "더 보기" 확장으로 인해 부모 요소의 크기가 증가하고 아래 행이 밀려나는 상황에서 모든 컴포넌트를 동기화하여 애니메이션을 적용하려면 명시적인 조정이 필요하지만, LayoutAnimation
을 사용하면 이를 간단히 처리할 수 있다.
LayoutAnimation
은 매우 강력하고 유용하지만, Animated
나 다른 애니메이션 라이브러리보다 제어 기능이 제한적이다. 따라서 원하는 동작을 구현하지 못할 경우 다른 방법을 고려해야 할 수도 있다.
Android에서 이 기능을 사용하려면 UIManager
를 통해 다음 플래그를 설정해야 한다:
UIManager.setLayoutAnimationEnabledExperimental(true);
이 예제는 미리 설정된 값을 사용한다. 필요에 따라 애니메이션을 커스터마이즈할 수 있으며, 자세한 내용은 LayoutAnimation.js를 참고한다.
추가 설명
requestAnimationFrame
requestAnimationFrame
은 브라우저에서 제공하는 폴리필로, 많은 개발자에게 익숙한 기능이다. 이 함수는 인자로 하나의 함수만 받으며, 다음 화면 재구성(repaint) 전에 해당 함수를 호출한다. 자바스크립트 기반 애니메이션 API의 핵심 구성 요소로, 모든 애니메이션의 기반이 된다. 일반적으로 이 함수를 직접 호출할 필요는 없다. 애니메이션 API가 프레임 업데이트를 자동으로 관리하기 때문이다.
setNativeProps
직접 조작(Direct Manipulation) 섹션에서 언급했듯이, setNativeProps
는 setState
를 호출하거나 컴포넌트 계층을 리렌더링하지 않고도 네이티브 뷰로 지원되는 컴포넌트(합성 컴포넌트와 달리 실제로 네이티브 뷰에 의해 지원되는 컴포넌트)의 속성을 직접 수정할 수 있게 해준다.
Rebound 예제에서 스케일을 업데이트하기 위해 이 기능을 사용할 수 있다. 업데이트하려는 컴포넌트가 깊게 중첩되어 있고 shouldComponentUpdate
로 최적화되지 않은 경우에 특히 유용하다.
애니메이션 프레임이 떨어지는 경우(초당 60프레임 미만으로 동작하는 경우), setNativeProps
나 shouldComponentUpdate
를 사용해 최적화를 고려해 볼 수 있다. 또는 useNativeDriver 옵션을 사용해 애니메이션을 JavaScript 스레드가 아닌 UI 스레드에서 실행할 수도 있다. 또한 InteractionManager를 사용해 애니메이션이 완료된 후에 계산이 많이 필요한 작업을 지연시킬 수도 있다. In-App Dev Menu의 "FPS Monitor" 도구를 사용해 프레임 속도를 모니터링할 수 있다.