트위터 앱 로딩 애니메이션을 React Native로 구현하기
Twitter의 iOS 앱에 있는 로딩 애니메이션이 마음에 든다.

앱이 준비되면 Twitter 로고가 확장되며 앱이 나타난다.
이 로딩 애니메이션을 React Native로 재현하는 방법을 찾아보고 싶었다.
이 애니메이션을 어떻게 구현할지 이해하기 위해, 먼저 로딩 애니메이션의 세부 요소를 분석했다. 미묘한 차이를 확인하려면 애니메이션 속도를 늦춰보는 것이 가장 좋다.

이 애니메이션에는 몇 가지 주요 요소가 있다. 이 요소들을 어떻게 구현할지 알아내야 한다.
- 새 모양의 로고를 확대한다.
- 로고가 커지면서 앱이 밑에서 나타난다.
- 마지막에 앱을 약간 축소한다.
이 애니메이션을 만드는 데 꽤 오랜 시간이 걸렸다.
처음에는 파란색 배경과 Twitter 로고가 앱 위에 있는 레이어라고 잘못 생각했다. 로고가 커지면서 투명해지면 앱이 나타나는 것이라고 가정했는데, 이 방식은 작동하지 않았다. 로고가 투명해지면 파란색 레이어가 보이지 앱이 보이지 않기 때문이다.
다행히 여러분은 내가 겪었던 실수를 반복하지 않고도 이 튜토리얼을 통해 바로 핵심을 배울 수 있다!
올바른 접근 방식
코드를 살펴보기 전에, 이 효과를 어떻게 분해할지 이해하는 것이 중요하다. 이 효과를 시각적으로 이해하기 위해 CodePen에서 재현했다(몇 단락 아래에 임베드됨). 이를 통해 여러분은 각 레이어를 인터랙티브하게 확인할 수 있다.

이 효과에는 세 가지 주요 레이어가 있다. 첫 번째는 파란색 배경 레이어다. 이 레이어는 앱 위에 나타나는 것처럼 보이지만, 실제로는 가장 뒤에 위치한다.
그 다음은 단순한 흰색 레이어다. 마지막으로 가장 앞쪽에는 앱이 위치한다.

이 애니메이션의 핵심은 Twitter 로고를 mask
로 사용해 앱과 흰색 레이어를 모두 마스킹하는 것이다. 마스킹의 세부 사항은 깊이 다루지 않겠다. 여기, 여기, 여기와 같은 온라인 리소스가 많이 있다.
이 컨텍스트에서 마스킹의 기본 개념은 마스크의 불투명한 픽셀이 마스킹하는 내용을 보여주고, 투명한 픽셀은 마스킹하는 내용을 숨기는 것이다.
Twitter 로고를 마스크로 사용해 두 레이어를 마스킹한다. 단색 흰색 레이어와 앱 레이어다.
앱을 드러내기 위해 마스크를 전체 화면보다 크게 확대한다.
마스크가 확대되는 동안 앱 레이어의 투명도를 서서히 높여 앱을 보여주고, 뒤에 있는 흰색 레이어를 숨긴다. 효과를 마무리하기 위해 앱 레이어를 1보다 큰 크기에서 시작해 애니메이션이 끝날 때 1로 축소한다. 그런 다음, 더 이상 보이지 않을 비앱 레이어를 숨긴다.
사진 한 장이 천 마디 말보다 낫다고 한다. 인터랙티브한 시각화는 몇 마디 말에 해당할까? "Next Step" 버튼을 클릭해 애니메이션을 단계별로 확인해 보자. 레이어를 보여주면 측면 뷰를 확인할 수 있다. 그리드는 투명한 레이어를 시각적으로 이해하는 데 도움을 준다.
React Native로 구현하기
이제 우리가 만들 애니메이션의 동작 방식을 이해했으니, 본격적으로 코드를 작성해 보자. 여러분이 이 글을 읽는 주된 이유일 것이다.
이 퍼즐의 핵심은 React Native의 코어 컴포넌트 중 하나인 MaskedViewIOS다.
import {MaskedViewIOS} from 'react-native';
<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;
MaskedViewIOS
는 maskElement
와 children
을 props로 받는다. children
은 maskElement
에 의해 마스킹된다. 마스크는 이미지일 필요가 없으며, 어떤 뷰든 가능하다. 위 예제의 동작은 파란색 뷰를 렌더링하지만, maskElement
의 "Basic Mask"라는 텍스트가 있는 부분에서만 보이게 된다. 우리는 단순히 복잡한 파란색 텍스트를 만든 것이다.
우리가 원하는 것은 파란색 레이어를 렌더링하고, 그 위에 Twitter 로고가 있는 마스크와 흰색 레이어를 렌더링하는 것이다.
{
fullScreenBlueLayer;
}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Image source={twitterLogo} />
</View>
}>
{fullScreenWhiteLayer}
<View style={{flex: 1}}>
<MyApp />
</View>
</MaskedViewIOS>;
이 코드는 아래와 같은 레이어를 만들어 준다.

애니메이션 구현하기
이제 모든 준비가 끝났으니, 다음 단계는 애니메이션을 적용하는 것이다. 자연스러운 애니메이션을 만들기 위해 React Native의 Animated API를 활용한다.
Animated를 사용하면 자바스크립트에서 선언적으로 애니메이션을 정의할 수 있다. 기본적으로 이 애니메이션은 자바스크립트에서 실행되며, 매 프레임마다 네이티브 레이어에 어떤 변경을 해야 하는지 알려준다. 하지만 자바스크립트가 매 프레임마다 애니메이션을 업데이트하려고 해도 충분히 빠르게 처리하지 못해 프레임이 누락되는 현상(지연)이 발생할 수 있다. 이는 우리가 원하는 결과가 아니다!
Animated는 이러한 지연 없이 애니메이션을 구현할 수 있도록 특별한 기능을 제공한다. Animated에는 useNativeDriver
라는 플래그가 있는데, 이는 애니메이션 시작 시 자바스크립트에서 네이티브로 애니메이션 정의를 전달한다. 이를 통해 네이티브 측에서 매 프레임마다 자바스크립트와 통신하지 않고도 애니메이션 업데이트를 처리할 수 있다. useNativeDriver
의 단점은 특정 속성만 업데이트할 수 있다는 점이다. 주로 transform
과 opacity
속성을 사용할 수 있다. useNativeDriver
로는 배경색과 같은 속성을 애니메이션할 수 없다. 아직은 그렇지만, 시간이 지나면서 더 많은 속성을 추가할 예정이다. 물론 필요한 속성을 직접 PR로 제출해 커뮤니티 전체에 기여할 수도 있다 😀.
이 애니메이션을 부드럽게 만들기 위해 이러한 제약 조건 내에서 작업할 것이다. useNativeDriver
의 내부 동작을 더 깊이 이해하려면 공식 블로그 포스트를 참고한다.
애니메이션 단계별 분석
애니메이션은 크게 4가지 단계로 구성된다:
- 새를 확대하며 앱과 흰색 레이어를 드러낸다.
- 앱을 서서히 나타나게 한다.
- 앱의 크기를 축소한다.
- 흰색 레이어와 파란색 레이어를 숨긴다.
Animated를 사용해 애니메이션을 정의하는 두 가지 주요 방법이 있다. 첫 번째는 Animated.timing
을 사용하는 방법으로, 애니메이션의 지속 시간과 움직임을 부드럽게 하는 이징 곡선을 직접 지정할 수 있다. 두 번째는 물리 기반 API인 Animated.spring
를 사용하는 방법이다. Animated.spring
에서는 스프링의 마찰력과 장력을 지정하고 물리 엔진이 애니메이션을 실행하도록 한다.
여러 애니메이션을 동시에 실행해야 하며, 이들은 서로 밀접하게 연관되어 있다. 예를 들어, 마스크가 드러나는 중간에 앱을 서서히 나타나게 해야 한다. 이러한 애니메이션은 서로 밀접하게 연결되어 있기 때문에, 단일 Animated.Value
를 사용해 Animated.timing
으로 구현할 것이다.
Animated.Value
는 애니메이션의 상태를 나타내는 네이티브 값을 감싼 래퍼다. 일반적으로 전체 애니메이션에 대해 하나의 Animated.Value
만 사용한다. Animated를 사용하는 대부분의 컴포넌트는 이 값을 상태에 저장한다.
이 애니메이션을 시간에 따라 진행되는 단계로 생각하기 때문에, Animated.Value
를 0(0% 완료)에서 시작해 100(100% 완료)로 끝나도록 설정할 것이다.
초기 컴포넌트 상태는 다음과 같다.
state = {
loadingProgress: new Animated.Value(0),
};
애니메이션을 시작할 준비가 되면, Animated에게 이 값을 100으로 애니메이션하도록 지시한다.
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true, // 이 부분이 중요하다!
}).start();
그런 다음 애니메이션의 각 부분과 전체 애니메이션 진행에 따라 원하는 값을 추정한다. 아래 표는 애니메이션의 각 부분과 시간에 따라 달라지는 값을 보여준다.
트위터 새 마스크는 스케일 1에서 시작해 작아졌다가 다시 크게 확대된다. 애니메이션의 10% 지점에서 스케일 값은 0.8이 되고, 마지막에는 스케일 70까지 확대된다. 70이라는 값은 사실 꽤 임의적으로 정한 것이다. 새가 화면을 완전히 드러낼 수 있을 만큼 충분히 커야 했고, 60으로는 부족했다.😀 흥미로운 점은 이 숫자가 클수록 같은 시간 안에 더 빠르게 커지는 것처럼 보인다는 것이다. 이 로고에 적합한 값을 찾기 위해 여러 번 시도했다. 로고나 디바이스 크기가 다르면 화면을 완전히 드러내기 위해 이 최종 스케일 값을 조정해야 한다.
앱은 트위터 로고가 작아지는 동안 불투명하게 유지되어야 한다. 공식 애니메이션을 참고해, 새가 확대되는 중간에 앱을 보이기 시작하고 빠르게 완전히 드러나도록 했다. 따라서 애니메이션의 15% 지점에서 앱이 보이기 시작하고, 30% 지점에서 완전히 나타난다.
앱의 스케일은 1.1에서 시작해 애니메이션 끝에 정상 크기로 축소된다.
코드로 구현하기
위에서 한 작업은 애니메이션 진행률을 각 요소에 적용할 값으로 매핑하는 것이다. Animated
의 .interpolate
를 사용해 이를 구현한다. this.state.loadingProgress
를 기반으로 보간된 값을 사용해 애니메이션의 각 부분에 대한 스타일 객체를 3개 생성한다.
const loadingProgress = this.state.loadingProgress;
const opacityClearToVisible = {
opacity: loadingProgress.interpolate({
inputRange: [0, 15, 30],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
// clamp는 입력이 30-100일 때 출력을 1로 유지한다는 의미
}),
};
const imageScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 10, 100],
outputRange: [1, 0.8, 70],
}),
},
],
};
const appScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 100],
outputRange: [1.1, 1],
}),
},
],
};
이제 이 스타일 객체를 사용해 앞서 설명한 뷰 스니펫을 렌더링할 수 있다. Animated.Value
를 사용하는 스타일 객체는 Animated.View
, Animated.Text
, Animated.Image
만 사용할 수 있다는 점에 유의한다.
const fullScreenBlueLayer = (
<View style={styles.fullScreenBlueLayer} />
);
const fullScreenWhiteLayer = (
<View style={styles.fullScreenWhiteLayer} />
);
return (
<View style={styles.fullScreen}>
{fullScreenBlueLayer}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Animated.Image
style={[styles.maskImageStyle, imageScale]}
source={twitterLogo}
/>
</View>
}>
{fullScreenWhiteLayer}
<Animated.View
style={[opacityClearToVisible, appScale, {flex: 1}]}>
{this.props.children}
</Animated.View>
</MaskedViewIOS>
</View>
);

이제 애니메이션 조각이 원하는 대로 보인다. 이제 더 이상 보이지 않을 파란색과 흰색 레이어를 정리하기만 하면 된다.
이 레이어를 언제 정리할지 알려면 애니메이션이 완료된 시점을 알아야 한다. 다행히 Animated.timing
을 호출할 때 .start
는 애니메이션이 완료되면 실행되는 콜백을 선택적으로 받는다.
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start(() => {
this.setState({
animationDone: true,
});
});
이제 애니메이션이 완료되었는지 알 수 있는 state
값을 가지게 되었으므로, 파란색과 흰색 레이어를 수정해 이 값을 사용할 수 있다.
const fullScreenBlueLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenBlueLayer]} />
);
const fullScreenWhiteLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenWhiteLayer]} />
);
이제 애니메이션이 작동하고, 애니메이션이 완료되면 사용하지 않는 레이어를 정리한다. 트위터 앱 로딩 애니메이션을 완성했다!
잠깐, 내 코드가 작동하지 않아요!
걱정하지 마세요. 저도 가이드에서 코드 조각만 제공하고 완성된 소스를 보여주지 않을 때 답답함을 느낍니다.
이 컴포넌트는 npm에 등록되어 있으며, GitHub에서 react-native-mask-loader로 확인할 수 있습니다. 여러분의 휴대폰에서 직접 테스트해보고 싶다면, Expo에서도 사용 가능합니다.

추가 학습 자료 / 심화 과제
- 이 gitbook은 React Native 문서를 읽은 후 Animated에 대해 더 깊이 배우기에 좋은 자료다.
- 실제 트위터 애니메이션은 끝 부분에서 마스크가 더 빨리 드러나는 것처럼 보인다. 로더를 수정해 다른 easing 함수(또는 spring!)를 사용해 그 동작을 더 잘 맞춰보자.
- 현재 마스크의 end-scale은 하드 코딩되어 있으며, 태블릿에서는 전체 앱이 드러나지 않을 가능성이 있다. 화면 크기와 이미지 크기를 기반으로 end scale을 계산해보는 것도 멋진 PR이 될 것이다.