쓰다 말음
클론코딩에 대해 좋게 생각하진 않지만, 오랜만에 재밌어보이는 주제가 있어서 클론 코딩을 해 보았습니다.
모든 화면을 애니메이션으로 떡칠한 카카오웹툰
앱인데요, 괴상한 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로 그라데이션을 정의한 다음 삼각함수로 빙빙 돌렸습니다.
짝짝짝 감탄하고 갑니다