[React Native 애니메이션] 파도 만들기 🌊

최근 svg 애니메이션을 이용해서 차트 그릴 일이 있었는데 너무 재밌더라구요! 그래서 애니메이션 구현 시리즈를 시작해보려고 합니다!

RN 개발자분들,, 애니메이션 화이팅..!!

--

--

그럼, 첫 애니메이션은 제가 좋아하는 파도 🌊부터!

사용한 라이브러리

라이브러리 세팅은 각 홈페이지 참고해 주세요 :)

Svg

먼저 파도를 담을 svg를 세팅해 줍니다.

import React from 'react';
import Svg from 'react-native-svg';

const SIZE = 300;

export default function Wave() {
  return (
    <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1" />
  );
}

잠깐 svg를 설명드리자면,

방향

svg는 x, y축을 기준으로 움직이지만 y축이 반대방향입니다.
따라서 y축 값이 커질 수록 아래로 내려가게 됩니다. 저도 처음엔 많이 헷갈렸어요 🤨

viewBox

viewBox의 앞 두 숫자는 svg가 시작하는 지점을,
뒤자리 두 개는 Svg에 style로 지정한 width, height를 어느 비율로 나타낼지를 정의하는 값입니다.

Svg의 width, height가 300px를 설정하고 viewBox="0 0 1 1"이라면,
기준 점이 (0, 0)이고 가로 300px을 1로, 세로 300px을 1로 약속하는 것입니다.

이렇게 하면 최대 길이를 1이라 생각할 수 있어서 비율을 계산할 때 직관적이라 좋더라구요!

먄약 픽셀값 그대로 사용하고 싶으시다면 아래처럼 SIZE값을 그대로 넣어 주시면 됩니다.

return (
    <Svg style={{width: SIZE, height: SIZE}} viewBox={`0 0 ${SIZE} ${SIZE}`} />
  );

파도 그리기, Path

svg에서 가장 자유도가 높은 path를 이용해서 파도가 치기 전 모습을 그려 봅시다.

곡선을 그릴 때는 컨트롤 포인트만 기억하세요!

svg에서 곡선을 그리는 방법은 많지만 이번에는 Bézier Curves 중 Cubic Curve를 사용해보겠습니다.

위의 설명처럼, 하나의 곡선을 그리기 위해서는

  • 컨트롤 포인트 2개
  • 끝나는 지점 좌표

이렇게 총 3개의 좌표가 필요합니다.
(시작 지점은 바로 직전의 값입니다.)

위 그림에서 가장 왼쪽 점이 시작점이고, 나머지 3개의 점 중 곡선 위에 없는 두 개의 점이 컨트롤 포인트입니다.

컨트롤 포인트의 위치에 따라 호의 모양이 달라지는 것을 잘 기억해 주세요!

이걸 응용해서 파도 모양을 만들어 보겠습니다.

import React from 'react';
import Svg, {Path} from 'react-native-svg';

const SIZE = 300;

export default function Wave() {
  const d = [
    'M 0 0.5',
    'C 0.3 0.2, 0.7 0.8, 1 0.5'
  ].join(" ");
  
  return (
    <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1">
      <Path d={d} fill="#1F5E9D" />
    </Svg>
  );
}

Path 경로에 들어간 코드를 하나씩 풀어보면 다음과 같습니다.

  • path의 시작 지점은 (0, 0.5)로 가장 왼쪽 정중앙.
  • 컨트롤 포인트 첫 번째(c1)는 (0.3, 0.2)로 좌측 상단.
  • 두 번째(c2)는 (0.7, 0.8)로 우측 하단.
  • 곡선이 끝나는 지점은 (1, 0.5)로 가장 오른쪽 정중앙.

두 컨트롤 포인트를 각각 상단과 하단에 배치했기 때문에 사인 그래프 형태로 표현할 수 있었습니다.

곡선은 완성하긴 했는데... 파도 처럼 보이지는 않죠?
아래를 채워야 할 것 같네요 🤔

export default function Wave() {
  const d = [
    'M 0 0.5',
    'C 0.3 0.2, 0.7 0.8, 1 0.5',
    'V 1',
    'H 0'
  ].join(" ");
  
  return (
    <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1">
      <Path d={d} fill="#1F5E9D" />
    </Svg>
  );
}

V는 수직 방향으로, H는 수평 방향으로 해당 좌표까지 직선을 그려주는 명령어입니다.

파도가 끝나는 우측 정중앙 지점에서 (1, 0.5)
아래로 1까지 쭉, (H 1)
그리고 왼쪽으로 0까지 (V 0) 쭉 그어주면!

이제야 넘실 넘실 파도 같네요!

움직이게, reanimated

RN에서 애니메이션을 구현하는 방법으로 기존에는 RN에서 기본적으로 제공하는 Animated API를 사용해야했으나 근본적인 성능 이슈가 있었습니다.

성능 이슈를 마법처럼 해결해주는 reanimated 라이브러리가 나온 이후부터는 그나마 극악이었던 RN 애니메이션의 희망이 되었다고 생각합니다. 🧙

어디가 변해야 할까?

파도를 치게 하려면 어디가 어떻게 변해야 할까요?

잠시 생각하는 타임-!

...
...
...

바로 바로 바로~

컨트롤 포인트의 위치를 y축 방향으로 왔다 갔다 하면 됩니다.

컨트롤 포인트가 변하면 곡선이 변하므로 컨트롤 포인트의 위치를 지속적이고,
반복적으로,
바꿔주면 파도가 출렁이게 되겠죠?

useSharedValue

이제는 코딩 타임입니다.

마치 react에서 useState hook을 이용해서 변하는 값을 정의하는 것처럼 reanimated 라이브러리에서는 useSharedValue hook을 사용합니다.

파도의 곡선이 위아래로 움직이기 때문에 변해야 할 값은 컨트롤 포인트의 y값입니다.

export default function Wave() {
  const c1y = useSharedValue(0.2);
  const c2y = useSharedValue(0.8);
  
  const d = [
    'M 0 0.5',
    `C 0.3 ${c1y.value}, 0.7 ${c2y.value}, 1 0.5`,
    'V 1',
    'H 0'
  ].join(" ");
  
  return (
    <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1">
      <Path d={d} fill="#1F5E9D" />
    </Svg>
  );
}

c1y, c2yuseSharedValue를 이용해 선언하고 값을 사용할 때는 value key로 접근하여 저장한 값을 사용합니다.

움직이게!

움직이게 하기 위해서는 useSharedValue로 저장한 값을 변경해야 합니다.

값을 변경하는 방법은 아주 간단한데요.
할당만 하면 됩니다.

c1y.value = 0.8;
c2y.value = 0.2;

첫 번째 컨트롤 포인트(c1)는 아래쪽으로,
두 번째 컨트롤 포인트(c2)는 위쪽으로,
두 컨트롤 포인트의 방향을 정반대로 바꾸려고 합니다.

버튼을 하나 만들어 해당 버튼을 눌렀을 때 움직이게 해봅시다.

export default function Wave() {
  const c1y = useSharedValue(0.2);
  const c2y = useSharedValue(0.8);

  const d = [
    'M 0 0.5',
    `C 0.3 ${c1y.value}, 0.7 ${c2y.value}, 1 0.5`,
    'V 1',
    'H 0',
  ].join(' ');

  const handleWave = () => {
    c1y.value = 0.8;
    c2y.value = 0.2;
  };

  return (
    <>
      <Button title="파도야 처라!" onPress={handleWave} />
      <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1">
        <Path d={d} fill="#1F5E9D" />
      </Svg>
    </>
  );
}

버튼을 눌러보면!!

.
.
.

움직이지 않습니다. 😬

왜 움직이지 않나요?

값도 잘 변경했는데 왜 움직이지 않을까요?

긴 글 주의...🚨

RN은 크게 다음으로 구성되어 있습니다.

  • JS Thread
  • UI Thread
  • Bridge

앞전에 RN에서 기본적으로 제공하는 Animated API에는 근본적인 이슈가 있다고 말씀드렸었는데요. Animated는 애니메이션 계산을 JS Thread에서 하고 그 값을 Bridge를 통해 UI Thread로 전달하며 화면에 보여줍니다.

그러다 보니 JS Thread에서 애니메이션 값을 계산하면서 다른 값을 계산하게 된다면 애니메이션을 처리하는데에 버벅이는 문제가 생깁니다.

이 문제를 해결한 것이 reanimated 라이브러리입니다. reanimated는 애니메이션 계산도 UI Thread에서 처리하기 때문에 Bridge를 거치지 않고 바로 계산된 값이 화면에 보여주기 때문에 보다 매끄러운 애니메이션을 구현할 수 있습니다.

그래서 이게 무슨 상관이야?

sharedValue는 UI Thread, JS Thread 둘 다에서 변경 가능하지만 UI Thread에서는 동기적으로, JS Thread에서는 비동기적으로 업데이트 됩니다.

즉, JS Thread에서 업데이트 하게 되면 바로 변화가 나타나지 않고 다음 렌더링 이후에 그 변화가 나타납니다. setState와 유사하죠.

  const handleWave = () => {
    c1y.value = 0.8;
    c2y.value = 0.2;
  };

handleWave 함수는 JS Thread에서 사용되기 때문에 비동기적으로 업데이트 되어 화면에 나타나지 않았습니다.

동기적으로 업데이트 하기

그렇다면 우리가 해야할 것은 sharedValue가 변경되었을 때 렌더링이 되도록 해야 합니다.

사실 이미 reanimated에서 만들어 둔 hook을 이용하면 간단하게 구현할 수 있습니다.

useAnimatedProps

우리가 바꾸려는 값이 Path의 props 중 경로(d)값이므로 useAnimatedProps로 정의한 props를 이용하면 동기적으로 업데이트할 수 있습니다.

useAnimatedProps로 정의한 값을 사용하기 위해서는 일반 Svg Path 대신 Animated API를 이용해서 animatedProps를 받을 수 있게 정의한 컴포넌트를 사용합니다. (reanimated의 Animated를 사용해야 합니다.)

import Animated, {
	useAnimatedProps,
} from 'react-native-reanimated';

const AnimatedPath = Animated.createAnimatedComponent(Path);

export default function Wave() {
  const c1y = useSharedValue(0.2);
  const c2y = useSharedValue(0.8);

  const animatedProps = useAnimatedProps(() => {
    return {
      d: [
        'M 0 0.5',
        `C 0.3 ${c1y.value}, 0.7 ${c2y.value}, 1 0.5`,
        'V 1',
        'H 0',
      ].join(' '),
    };
  });

  const handleWave = () => {
    c1y.value = 0.8;
    c2y.value = 0.2;
  };

  return (
    <>
      <Button title="파도야 처라!" onPress={handleWave} />
      <Svg style={{width: SIZE, height: SIZE}} viewBox="0 0 1 1">
        <AnimatedPath animatedProps={animatedProps} fill="#1F5E9D" />
      </Svg>
    </>
  );
}

이제 다시 버튼을 눌러볼까요!?

드디어 바뀌었네요!
하지만 정내미 없이 뚝 바뀌어서 아쉽습니다.
변하는 과정을 모두 보여주고 싶네요.

withTiming

시간에 따른 변화 과정을 모두 보여줍니다.
더 많은 옵션은 문서를 참고해 주세요!

const handleWave = () => {
  c1y.value = withTiming(0.8, {duration: 500});
  c2y.value = withTiming(0.2, {duration: 500});
};

거의 다 왔습니다.

파도가 지속적으로 움직이게 하기 위해서는 이 작업을 반복하면 되겠죠?

withRepeat

  const handleWave = () => {
    c1y.value = withRepeat(withTiming(0.8, {duration: 500}), -1, true);
    c2y.value = withRepeat(withTiming(0.2, {duration: 500}), -1, true);
  };

withRepeat의 첫번째 인자는 반복한 animation,
두 번째는 반복 횟수, (-1이면 무한)
세 번째는 반복이 끝날때마다 reverse 여부입니다.

자, 다 된 것 같은데 뭔가... 어색합니다

이유는 컨트롤 포인트가 동시에 움직이다 보니 파도가 한쪽 방향으로 움직이는 느낌이 나지 않고 제자리에서 꿀렁거리는 것 같네요.

그렇다면 컨트롤 포인트가 움직이는 타이밍을 다르게 줘볼까요?

withDelay

  const handleWave = () => {
    c1y.value = 
      withRepeat(withTiming(0.8, {duration: 500}), -1, true));
    c2y.value = withDelay(
      200,
      withRepeat(withTiming(0.2, {duration: 500}), -1, true),
    );
  };

c2에 200ms 늦게 애니메이션이 시작하게 했습니다.

자 그럼 결과를 볼까요!?!?

세상 뿌듯하네요 🌊 🏄‍♂️

매무리

이렇게 적어보니 아주 스무스하게 구현한 것 같지만 우여곡절이 아주 많았습니다.. ㅎㅎ

다 하는데 4시간 넘게 걸렸는데요. 그래도 오랜만에 하고 싶은 개발을 하니 시간 가는줄 모르고 몰입할 수 있었던 것 같습니다.

앞으로 꾸준히 애니메이션 시리즈를 올려보도록 할게요! 저를 위해! ㅋㅋㅋ

코드는 깃헙에 올려뒀습니다!

https://github.com/dipsiiiiiiiiii/reanimated-playgrounds/blob/master/src/screens/wave/Wave.js

그럼 다들 오늘도 화이팅👍

좋은 웹페이지 즐겨찾기