쓰다 말음
클론코딩에 대해 좋게 생각하진 않지만, 오랜만에 재밌어보이는 주제가 있어서 클론 코딩을 해 보았습니다.
모든 화면을 애니메이션으로 떡칠한 카카오웹툰 앱인데요, 괴상한 UX로 출시 직후에 혹평이 많았던것으로 기억합니다.
나쁜 UX와는 별개로 클론코딩 해보기에는 꽤 재미있어보여서 한번 도전해보게 되었습니다.
0.3을 곱하면 너무 느리고, 0.5를 곱하면 너무 빠른데, 0.4을 곱하면 적당해보이는 경우가 있습니다. 그리고 좀 더 테스트해서 대충 0.425를 곱하면 부드러운 애니메이션이 나오는 그런 경우가 많은데, 문제는 0.425란 값이 계산에 의해 나온 의미있는값이 아니라, 아무거나 막 넣어서 나온 그중에 보기좋은 값이기 때문이죠.navigate() 함수를 호출하면 알아서 슬라이드 애니메이션과 함께 화면이 전환되듯이요.그래도 두번째 목표는 절반은 성공했다고 보기 때문에 그 절만을 어떻게 처리했는지를
이 글에서 다뤄 보겠습니다.
이 프로젝트는 아래 라이브러리를 사용했습니다.
아래 내용들은 라이브러리 사용법과 어느정도 연관이 있습니다.
https://github.com/software-mansion/react-native-reanimated
모든 애니메이션은 0과 1사이에서 시작합니다.
만약 컴포넌트를 오른쪽으로 400px밀고 싶으면 값을 0~400 로 애니메이션하는게 아니라, 0~1 로 애니메이션 하고 v * 400 을 해야 합니다.
여기에는 두가지 이유가 있습니다.
0~1 은 재활용이 되는 값인데, 0~400 은 재활용이 안되는 값입니다.276 이면, 이게 실제로 애니메이션의 어느 부분인지 알기 어렵습니다. 특히 애니메이션 범위가 -430 ~ +675 이런식으로 되어있으면 그중에 276은 어느정도 온건지 한눈에 보기 더 어렵습니다.ReactNative::Animated.interpoation
아래는 프로젝트에서 사용된 애니메이션 함수들입니다.
export const clamp = (t: number) => {
return Math.max(0, Math.min(1, t));
};
export const amplify = (v: number, t: number) => {
return clamp(v * t);
};
export const lerp = (from: number, to: number, t: number) => {
return from + (to - from) * t;
};
export const stop = (stop: number, t: number) => {
if (t <= stop) return 0;
return (t - stop) * (1 / (1 - stop));
};
export const rescale = (from: number, to: number, t: number) => {
if (t <= from) return 0;
if (t >= to) return 1;
const scale = 1 / (to - from);
return clamp(from + (t - from) * scale);
};
clamp(0.5)은 0.5 입니다.clamp(-0.1)은 0 입니다.from에서 to로 t만큼 보간합니다.0~1 애니메이션을 0 ~ 0 ~ 1로 변경합니다.0~1 애니메이션을 0 ~ 0 ~ 1 ~ 1로 변경합니다.amplify, stop, rescale 함수는 애니메이션의 정확한 속도와 위치를 설정하는 함수가 아닙니다.
정확하게 애니메이션을 지연하고, 속도를 조절하려면 애니메이션 duration, delay 등을 설정해야 합니다.
다만 이 함수들은 아래와 같은 상황에 매우 유용하게 쓰입니다.
화면에 서로 다른 delay를 가지는 9개의 아이템이 있습니다.
이에 대한 구현은 9개의 서로 다른 delay를 가지는 9개의 애니메이션을 사용하는 대신 1개의 0~1 애니메이션과 rescale 함수로 처리할 수 있습니다.
const containerStyle = useAnimatedStyle(() => {
const t = rescale(
index * 0.08,
index * 0.08 + 0.5,
fadeIn.value,
);
return {
opacity: Math.pow(t, 2),
transform: [ { translateY: (1 - t) * 40 }],
};
});
index는 몇번째 아이템인지를 나타냅니다.
결과적으로 각 아이템은 n번째 * 0.08초 의 딜레이와, duration * 0.5의 duration으로 동작합니다.
(원본 값에 easing이 붙어있으면 정확하게 위 처럼 동작하지 않습니다. 그래서 대략적 으로 조절할 수 있는 점에 주의해야 합니다.)
위처럼 1개의 애니메이션 값으로 재활용하는 경우도 있겠지만, 실제로는 여러개의 값을 사용하고, 그 값들을 순차적으로 재생해야 하는 경우가 더 많습니다.
저는 ValueSequenceProvider 컴포넌트를 만들어 순차적인 애니메이션 실행을 처리하도록 했습니다.
아래는 대략적인 사용 코드입니다.
return (
<ValueSequenceProvider
sequence={[
{ duration: 2000 },
{ name: '별 날아다니는거 지우기', duration: 300, easing: Easing.exp },
{ name: '위 아래로 합치는거', duration: 300 },
{ duration: 500 },
{ name: '꺾기', duration: 300 },
{ duration: 100 },
{ name: '헤더 내려오기', duration: 500, parallel: true },
{ name: '아이템 fadeIn', duration: 300 },
]}
>
{/* 메인 화면 */}
</ValueSequenceProvider>
);
각 값들은 name 으로 지정한 key로 가져올 수 있습니다.
const v = useAnimatedValue('KEY');`
자기 차례가 되면 자동으로 0에서 1로 애니메이션 되며, 자기 차례 이전에는 계속 0값을 가집니다.
만약 범위를 바꾸고 싶다면 지정할 수도 있습니다.
{ name: 'v', from: 1, to: 0 },
실제 코드에서는 sequence에 들어갈 값을 상수화해 UI 코드에서 분리할 수 있습니다.
const MainPage = () => {
return (
<ValueSequenceProvider
sequence={MainAnimationSequence}
>
{/* ... */}
</ValueSequenceProvider>
);
};
const MainAnimationSequence = [
{ blah blah },
{ blah blah },
];
FadeIn 애니메이션은 아래와 같이 간단하게 처리했습니다.
<FadeInProvider
delay={100}
duration={500}
>
<Container>
<FadeInProvider
delay={500}
duration={300}
>
<CharacterPortrait />
</FadeInProvider>
</Container>
</FadeInProvider>
FadeInProvider는 fadeIn 이라는 key의 애니메이션 값을 제공합니다.
아래의 Wrapper 컴포넌트들은 fadeIn 값을 이용해 자동으로 opacity 애니메이션을 실행합니다.
<FadeImage />
<FadeText />
<FadeView />
만약 opacity이외에 다른 값으로 애니메이션을 적용하고 싶다면 아래와 같이 직접 애니메이션 값에 접근할 수 있습니다.
const fadeIn = useAnimatedValue('fadeIn');
FadeInProvider는 추가적으로 아래와 같은 특징을 가집니다.
FadeInProvider는 중첩될 수 있습니다. 상위 FadeInProvider가 완료된 후에 아래의 FadeInProvider 가 실행됩니다.CharacterPortrait는 실제로 600ms후에 실행됩니다.FadeInProvider는 ValueSequence를 재사용하며, 아래와 같은 단순한 구조를 가집니다.
<ValueSequenceProvider
play={play}
sequence={[
{ duration: delay },
{ name: 'fadeIn', duration, easing: Easing.exp },
]}
>
{children}
</ValueSequenceProvider>
몇몇 오브젝트는 단순히 FadeIn만 가지는것이 아니라 FadeOut 효과도 가지고 있어야 합니다.
카카오웹툰 앱에서 상단에서 내려오는 메뉴가 가장 어울리는 예제일것 같아서 가져왔습니다.
이를 위해 FadeProvider를 만들었는데, 대략적인 사용법은 아래와 같습니다.
<FadeProvider
show={show}
sequence={[
{ name: 'bar1', duration: 250, parallel: true },
{ duration: 100 },
{ name: 'bar2', duration: 250, parallel: true },
{ duration: 100 },
{ name: 'bar3', duration: 250, parallel: true },
{ duration: 300 },
{ name: 'fadeIn', duration: 500 },
]}
>
<Bar name="bar1" />
<Bar name="bar2" />
<Bar name="bar3" />
</FadeProvider>
FadeProvider는 show 가 true이면 시퀸스를 정방향으로 재생하고show 가 false가 되면 역방향으로 재생합니다.const Bar = ({
name,
}) => {
const offsetY = useAnimatedValue(name);
const barStyle = useAnimatedStyle(() => {
return {
transform: [ { translateY: lerp(-height, 0, offsetY.value) }],
};
});
return (
<Bar style={barStyle}>
{children}
</Bar>
);
};
한가지 더 고려할 점은, show == false인 상황에서 children을 그릴 지 여부를 판단해야 한다는 것 입니다. 만약 opacity가 0이어서 화면에 보이지 않는다고 하더라도, 여전히 내용물을 렌더링한다면 터치 이벤트를 비롯한 각종 버그가 발생하게 됩니다.
이를 해결하기 위해 FadeProvider는 내부적으로 shouldRenderChildren 상태를 가지고 있습니다.
{shouldRenderChildren && children}
FadeInOut은 show 상태에 의해 명시적으로 처리되는 애니메이션이지만, 사용자 정의 state가 아니라 네비게이션 상태에 따른 애니메이션도 필요합니다.

위 사진은 뒤로가기 버튼을 누르면 이전 페이지로 돌아가면서 자동으로 재생되는 애니메이션인데, 이를 위해 usePageInOutValue 훅을 만들었습니다.
const pageInOut = usePageInOutValue({
duration: 500,
easing: Easing.ease,
});
위와 같은 간단한 구조를 가지며
페이지 enter시 0~1
페이지 leave시 1~0 의 값으로 자동 애니메이션됩니다.
useAnimatedStyle을 사용하면 코드가 굉장히 길어집니다.
const headerStyle = useAnimatedStyle(() => {
return {
opacity: fadeIn.value,
};
});
const iconStyle = useAnimatedStyle(() => {
return {
opacity: fadeIn.value,
};
});
const footerStyle = useAnimatedStyle(() => {
return {
opacity: fadeIn.value,
};
});
const containerStyle = useAnimatedStyle(() => {
return {
opacity: fadeIn.value,
};
});
return (
<Container
style={containerStyle}
>
</Container>
);
심지어 useAnimatedStyle 의 리턴값은 단 1번밖에 사용할 수 없으며, 똑같은 스타일을 다른 컴포넌트에 다시 사용하려면 useAnimatedStyle을 한번 더 써야합니다. (라이브러리 제약사항)
저는 이부분을 hoc을 만들어 애니메이션 코드를 컴포넌트 바디가 아닌 styled-components 부분으로 옮길 수 있도록 했습니다.
const Container = withAnimatedStyle(['fadeIn'], ({
fadeIn,
}) => ({
opacity: fadeIn.value,
}), styled(Animated.View)`
width: 100%;
height: 200px;
`);
첫번째 인자는 deps 배열이며, 해당하는 각 name의 animatedValue 값을 찾아서 두번째 콜백에 넣어줍니다.
실제 withAnimatedStyle의 구현은 아래와 같이 되어있습니다.
type SharedValueRecord = Record<string, Animated.SharedValue<number>>;
export const withAnimatedStyle = (
deps: string[],
callback: (x: SharedValueRecord) => any,
Component: React.FC,
) => forwardRef((props: any, ref) => {
const v = deps.reduce<SharedValueRecord>((p, v) => {
p[v] = useAnimatedValue(v);
return p;
}, {});
const p = omit(props, ['style', 'children']);
const style = useAnimatedStyle(() => {
return callback({ ...v, ...p });
}, [props]);
return (
<Component
{...props}
ref={ref}
style={[...[Array.isArray(props.style) ? props.style : [props.style]], style]}
/>
);
});
이렇게 애니메이션 코드를 분리함으로써, 비즈니스 로직이 들어와도 최대한 코드가 엉망이 되지 않도록 했습니다.
메뉴바는 특별한건 많이 없습니다.
기본적으로 SVG로 모양을 만들고, width, scale 값을 애니메이션했고,
특이한점은 테두리가 그라데이션으로 빙빙 도는 모양을 하고 있는데 이 또한 SVG linear-gradient로 그라데이션을 정의한 다음 삼각함수로 빙빙 돌렸습니다.

짝짝짝 감탄하고 갑니다