카카오 웹툰 클론 코딩하기

Park June Chul·2021년 9월 12일
32

React

목록 보기
3/7
post-custom-banner
쓰다 말음

머리말

클론코딩에 대해 좋게 생각하진 않지만, 오랜만에 재밌어보이는 주제가 있어서 클론 코딩을 해 보았습니다.

모든 화면을 애니메이션으로 떡칠한 카카오웹툰 앱인데요, 괴상한 UX로 출시 직후에 혹평이 많았던것으로 기억합니다.
나쁜 UX와는 별개로 클론코딩 해보기에는 꽤 재미있어보여서 한번 도전해보게 되었습니다.

목표

  • 80~90% 정도만 비슷하게 만들기
    • 기존에도 앱 애니메이션을 만들어본 경험이 있는데, 100% 똑같게 만들기는 매우 힘듭니다. 예를들어 y에 0.3을 곱하면 너무 느리고, 0.5를 곱하면 너무 빠른데, 0.4을 곱하면 적당해보이는 경우가 있습니다. 그리고 좀 더 테스트해서 대충 0.425를 곱하면 부드러운 애니메이션이 나오는 그런 경우가 많은데, 문제는 0.425란 값이 계산에 의해 나온 의미있는값이 아니라, 아무거나 막 넣어서 나온 그중에 보기좋은 값이기 때문이죠.
    • 실제로 카카오 웹툰 안드로이드 버전과 iOS 버전도 애니메이션이 서로 다릅니다.
  • 애니메이션 코드 깔끔하게 처리하기
    • 애니메이션만 따라 만드는건 그다지 도전적인 목표가 아닙니다. 그냥 많은 시간과 코드를 때려박아서 노가다 작업만 하면 되거든요.
    • 각 컴포넌트는 화면에는 애니메이션이 있으면서, 코드에는 마치 없는것처럼 짜는게 목표여야 합니다. 마치 navigate() 함수를 호출하면 알아서 슬라이드 애니메이션과 함께 화면이 전환되듯이요.

결과

결과물 유튜브 영상

  • 첫번째 목표는 어느정도 성공했습니다. 앱의 모든 기능을 다 구현할 순 없지만, 구현해보기로 마음먹은 부분은 어느정도 비슷하게 구현한것 같습니다. (물론 폴리싱 해야할 곳은 좀 많습니다.)
  • 두번째는 절반 정도만 성공했습니다. 애니메이션 코드는 여전히 컴포넌트 코드와 공생하고, 만약 이게 프로덕션 앱이라면 비즈니스 로직 코드들까지 더해져 엄청난 난장판이 될 것입니다.

그래도 두번째 목표는 절반은 성공했다고 보기 때문에 그 절만을 어떻게 처리했는지를
이 글에서 다뤄 보겠습니다.

못한것

  • 인트로에서 동영상과 이미지가 블렌딩되는데, React Native 이미지에 블렌딩 기능이 아예 없습니다. 이번에 expo환경으로 만들면서 라이브러리 사용도 매우 제한적이여서 그냥 제외했습니다.
  • 메인 화면 무한 스크롤: 아래로 무한스크롤하는건 쉬운데, 위쪽으로도 무한스크롤하려면 어느정도 삽질이 필요합니다. 무한스크롤 만드는것이 목표는 아니라 제외했습니다.

react-native-reanimated

이 프로젝트는 아래 라이브러리를 사용했습니다.
아래 내용들은 라이브러리 사용법과 어느정도 연관이 있습니다.
https://github.com/software-mansion/react-native-reanimated

기초

모든 애니메이션은 01사이에서 시작합니다.

만약 컴포넌트를 오른쪽으로 400px밀고 싶으면 값을 0~400 로 애니메이션하는게 아니라, 0~1 로 애니메이션 하고 v * 400 을 해야 합니다.
여기에는 두가지 이유가 있습니다.

  • 0~1 은 재활용이 되는 값인데, 0~400 은 재활용이 안되는 값입니다.
    • 가장 큰 이유입니다. 애니메이션은 여러 컴포넌트에서 재생되는데, 각 컴포넌트마다 다른 범위의 애니메이션 값을 가지면 난장판이 됩니다.
  • 디버깅 하기 쉽습니다.
    • 로그로 찍은 값이 276 이면, 이게 실제로 애니메이션의 어느 부분인지 알기 어렵습니다. 특히 애니메이션 범위가 -430 ~ +675 이런식으로 되어있으면 그중에 276은 어느정도 온건지 한눈에 보기 더 어렵습니다.

ReactNative::Animated.interpoation

Interpolation, Easing and Utility

아래는 프로젝트에서 사용된 애니메이션 함수들입니다.

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~1 사이로 만들어줍니다.
    • clamp(0.5)0.5 입니다.
    • clamp(-0.1)0 입니다.
    • 이 함수는 애니메이션 버그를 방지합니다.
  • lerp: 값을 from에서 tot만큼 보간합니다.
  • amplify: 값을 증폭합니다.
    • 이 함수는 애니메이션 속도를 조절합니다.
  • stop: 0~1 애니메이션을 0 ~ 0 ~ 1로 변경합니다.
    • 이 함수는 애니메이션에 간단한 딜레이를 줄 수 있습니다.
  • rescale: 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 처리하기

FadeIn 애니메이션은 아래와 같이 간단하게 처리했습니다.

<FadeInProvider
  delay={100}
  duration={500}
>
  <Container>
    <FadeInProvider
      delay={500}
      duration={300}
    >
      <CharacterPortrait />
    </FadeInProvider>
  </Container>
</FadeInProvider>

FadeInProviderfadeIn 이라는 key의 애니메이션 값을 제공합니다.

아래의 Wrapper 컴포넌트들은 fadeIn 값을 이용해 자동으로 opacity 애니메이션을 실행합니다.

<FadeImage />
<FadeText />
<FadeView />

만약 opacity이외에 다른 값으로 애니메이션을 적용하고 싶다면 아래와 같이 직접 애니메이션 값에 접근할 수 있습니다.

const fadeIn = useAnimatedValue('fadeIn');

FadeInProvider는 추가적으로 아래와 같은 특징을 가집니다.

  • FadeInProvider는 중첩될 수 있습니다. 상위 FadeInProvider가 완료된 후에 아래의 FadeInProvider 가 실행됩니다.
  • 위 코드에서 CharacterPortrait는 실제로 600ms후에 실행됩니다.
  • 이는 같은 컴포넌트 내부에서도 유용하지만, 실제로 다른 컴포넌트의 상하관계에서, 상위 Container가 완전히 보이는 상태에서 Children의 애니메이션이 실행되게 해줍니다.

FadeInProviderValueSequence를 재사용하며, 아래와 같은 단순한 구조를 가집니다.

<ValueSequenceProvider
  play={play}
  sequence={[
    { duration: delay },
    { name: 'fadeIn', duration, easing: Easing.exp },
  ]}
>
  {children}
</ValueSequenceProvider>

FadeIn & FadeOut 처리하기

몇몇 오브젝트는 단순히 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>
  • FadeProvidershow 가 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}

페이지 네비게이션 처리하기

FadeInOutshow 상태에 의해 명시적으로 처리되는 애니메이션이지만, 사용자 정의 state가 아니라 네비게이션 상태에 따른 애니메이션도 필요합니다.

위 사진은 뒤로가기 버튼을 누르면 이전 페이지로 돌아가면서 자동으로 재생되는 애니메이션인데, 이를 위해 usePageInOutValue 훅을 만들었습니다.

const pageInOut = usePageInOutValue({
  duration: 500,
  easing: Easing.ease,
});

위와 같은 간단한 구조를 가지며
페이지 enter시 0~1
페이지 leave시 1~0 의 값으로 자동 애니메이션됩니다.

useAnimatedStyle 격리하기

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로 그라데이션을 정의한 다음 삼각함수로 빙빙 돌렸습니다.

profile
다른 곳에서 볼 수 없는 이상한 주제를 다룹니다. https://pjc0247.github.io/new-home
post-custom-banner

5개의 댓글

comment-user-thumbnail
2021년 9월 16일

짝짝짝 감탄하고 갑니다

답글 달기
comment-user-thumbnail
2021년 9월 17일

매직매직 애니메이션

1개의 답글
comment-user-thumbnail
2021년 9월 21일

오오오오오오옹 이쁘게 잘 만드셨어여

답글 달기
comment-user-thumbnail
2021년 9월 21일

우왕

답글 달기