1. 개요

이전 글까지는 아래와 같은 작업들을 진행했다.

오늘은 애니메이션 기능을 구현해보자.

2. 구현

애니메이션을 적용해야 할 곳은 두 군데가 있다.

  • 데이터를 표시하는 라인들
  • X 축, Y 축, 그리드라인

차례대로 적용해보자.

2.1. 라인에 애니메이션 적용

라인차트의 라인들은 <Lines /> 컴포넌트로 구현되어 있다. 해당 코드를 잠깐 살펴보면 아래와 같다.

comps/LineChart/LineChartBody/Lines.tsx
import { G, Path } from "react-native-svg";

// ...

function Lines(/* ... */) {
  return (
    <G>
      {series.map((sr, i) =>
        !sr.visible ? null : (
          <Path
            key={i}
            d={lineFunc(sr.data)}
            stroke={sr.color ?? colors[i % colors.length]}
            strokeLinecap="round"
            fill="transparent"
            strokeWidth={sr.lineWidth ?? lineWidth}
          />
        )
      )}
    </G>
  );
}

여기서 시리즈별로 라인을 그리는 <Path /> 컴포넌트에 애니메이션을 적용해야 한다.

한 컴포넌트에서 여러 개의 애니메이션을 관리하기는 까다로우므로 아래처럼 <Path /> 컴포넌트를 한 번 래핑해서 각각의 애니메이션을 관리하도록 하겠다.

comps/LineChart/Path/index.tsx
import { Path as RNSPath } from "react-native-svg";
import AnimatedPath, { AnimatedPathProps } from "./AnimatedPath";

export type PathProps = AnimatedPathProps & {
  animated?: boolean;
};
function Path({ animated, duration, interpolater, ...props }: PathProps) {
  if (animated === false) {
    return <RNSPath {...props} />;
  }

  return (
    <AnimatedPath duration={duration} interpolater={interpolater} {...props} />
  );
}

export default Path;

애니메이션을 비활성화 했을 경우 기존의 react-native-svg 의 <Path /> 컴포넌트를 그대로 쓰도록 했으며, 애니메이션을 활성화되어 있는 경우에는 <AnimatedPath /> 를 쓰도록 했다. 이 <AnimatedPath /> 를 구현해보자.

2.1.1. react-native-svg 컴포넌트에 애니메이션 적용 방법

시작하기 전에 주의할 것이 있다. react-native-svg 의 컴포넌트들은 컴포넌트에 따라 react-native 의 Animated 값을 prop 에 대입해서 사용하는 것이 어렵다. 아래 예제 코드를 보자.

import { G, Line, Text, Path } from "react-native-svg";
const AnimG = Animated.createAnimatedComponent(G);
const AnimLine = Animated.createAnimatedComponent(Line);
const AnimText = Animated.createAnimatedComponent(Text);
const AnimPath = Animated.createAnimatedComponent(Path);

const SampleComp = () => {
  const anim = useRef(new Animated.Value(0)).current;

  // ...

  return (
    <AnimG
      // ...
      x={anim} // 에러는 나지 않지만 동작하지도 않음
    >
      <AnimLine
        // ...
        x={anim} // 잘 동작함
      />
      <AnimText
        // ...
        x={anim} // ERROR: java.lang.Double cannot be cast to ReadableArray. 버그로 추정
      >
        text
      </AnimText>
      <AnimPath
        // ...
        d={anim.interpolate(/* ... */)} // 안 되는 것으로 알고 있음. 까다로워서 시도해보지도 않음
      />
    </AnimG>
  );
};

일반적인 상황에서 잘 써오던 애니메이션 패턴이 많은 경우에 제대로 동작하지 않는 것을 볼 수 있다.

이 글에서 애니메이션 작업에는 <Path />, <G />, <Text /> 등의 react-native-svg 컴포넌트들을 사용한다. 따라서 Animated 를 직접 사용하지 않는 다른 방법을 찾아야 한다. 따라서 우리는 AnimatedsetNativeProps() 를 조합해서 애니메이션을 구현할 것이다.

setNativeProps() 는 컴포넌트의 prop 을 직접 수정할 수 있도록 RN 측에서 제공하는 함수다. 성능 이슈가 있으므로 일반적인 상황에서는 사용을 권장하지 않는다.

그럼 한 번 사용해보자.

2.1.2. 데이터 변경 시 애니메이션 구현

comps/LineChart/Path/AnimatedPath.tsx
import { useEffect, useRef } from "react";
import { Path, PathProps } from "react-native-svg";
import { Animated } from "react-native";
import { interpolatePath as d3InterpolatePath } from "d3-interpolate-path";

export type AnimatedPathProps = PathProps & {
  duration?: number;
  interpolater?: (prev: string, current: string) => (delta: number) => string;
};
function AnimatedPath({
  d,
  duration = 300,
  interpolater = d3InterpolatePath,
  ...props
}: AnimatedPathProps) {
  const anim = useRef(new Animated.Value(0)).current;
  const prevDRef = useRef<string>();
  const pathRef = useRef<Path>(null);

  useEffect(() => {
    // 이전 `d` 값을 저장해둔다.
    const prevD = prevDRef.current ?? d;
    prevDRef.current = d;

    if (prevD === undefined || d === undefined) {
      return;
    }

    if (prevD === d) {
      // <Path /> 에 직접 d prop 을 지정하지 않고 여기서 이렇게 지정하는 이유는
      // <Path /> 에 직접 d 를 지정하면 d 가 변경되는 순간 <Path /> 가 리렌더링되고
      // 그 이후에 prevD 로부터 d 로의 애니메이션 코드가 시작되기 때문이다.
      // 사용자 입장에서 애니메이션이 매우 어색할 것이다.
      pathRef.current?.setNativeProps({ d } as any);
      return;
    }

    // 이전 `d` 값과 현재 `d` 값을 가지고 애니메이션 도중에 어떤 `d` 값을 사용해야 하는지
    // 계산하는 함수를 생성한다.
    // 이런 함수를 생성하는 코드를 직접 작성하는 건 어려우므로
    // d3-interpolate-path 라는 라이브러리에서 제공하는 함수를 기본값으로 사용하고 있다.
    const interpolatePath = interpolater(prev, current);

    // `addListener` 로 등록한 함수가 anim 의 값이 바뀔 때마다 실행된다.
    const listenerId = anim.addListener(({ value }) => {
      const path = interpolatePath(value);
      // - `interpolatePath` 결과 값을 <Path /> 의 d 에 직접 지정.
      //   코드가 실행되면 화면에 바로 적용된다.
      // - `setNativeProps` 의 인자 타입에 `d` 가 포함되어있지 않아 어쩔 수 없이 `any` 로 형변환.
      //   동작 자체는 문제 없다.
      pathRef.current?.setNativeProps({ d: path } as any);
    });

    // 애니메이션을 시작하는 코드는 일반적인 `Animated` 사용과 동일하다.
    const animated = Animated.timing(anim, {
      toValue: 1,
      duration,
      useNativeDriver: true,
    });

    animated.start(() => {
      anim.removeListener(listenerId);
      // 애니메이션이 끝나면 다시 값을 0으로 되돌린다.
      // 바로 윗줄에서 리스너를 제거했기 때문에 0으로 변경해도 아무 일도 일어나지 않는다.
      // anim 의 값이 항상 0 에서 1 로 변경되도록 해 로직을 단순하게 유지하기 위해서이다.
      anim.setValue(0);
    });

    return () => animated.stop();
  }, [d]);

  return <Path ref={pathRef} {...props} />;
}

export default AnimatedPath;

이렇게 하면 이제 series 값이 바뀔 때마다 애니메이션이 동작하는 것을 볼 수 있다.

2.1.3. 등/퇴장 시 애니메이션 구현

이제 선이 등장 및 퇴장할 때도 애니메이션을 적용해보자. 그냥 opacity 만 조정하는 건 재미가 없으므로 많은 (많은 차트 라이브러리에서 사용하고 있는) strokeDasharray 를 사용해서 애니메이션을 구현할 것이다.

(위 이미지의 Highchsrts 차트처럼 동작하는 애니메이션이 strokeDasharray 를 사용한 애니메이션이다.)

애니메이션을 구현하기에 앞서 수정해야 할 것은 <Lines /> 컴포넌트다. 여기서 sr.visible === false 일 경우 <Path /> 컴포넌트가 아니라 null 을 렌더링하고 있는데

comps/LineChart/LineChartBody/Lines.tsx
      {series.map((sr, i) =>
        !sr.visible ? null : (
          <Path

이래서야 퇴장 애니메이션을 보여줄 수 없다. 퇴장 애니메이션을 보여줄 수 있도록 조금만 수정하자.

comps/LineChart/LineChartBody/Lines.tsx
// ...

function Lines(/* ... */) {
  return (
    <G>
      {series.map((sr, i) => (
        <Path
          key={i}
          // `visible` 이 `false` 일 경우 `d` 에 `undefined` 를 넣어준다.          // 이러면 `<Path />` 안에서 (정확히는 `<AnimatedPath />` 안에서)          // 퇴장 애니메이션을 실행할 시기를 알 수 있게 된다.          d={!sr.visible ? undefined : lineFunc(sr.data) ?? undefined}          stroke={sr.color ?? colors[i % colors.length]}
          strokeLinecap="round"
          fill="transparent"
          strokeWidth={sr.lineWidth ?? lineWidth}
          visible={sr.visible}
          animated={animatable}
        />
      ))}
    </G>
  );
}

export default Lines;

그리고 <AnimatedPath /> 에 애니메이션을 구현하자.

comps/LineChart/Path/AnimatedPath.tsx
// ...
function AnimatedPath({
  d,
  duration = 300,
  interpolater = d3InterpolatePath,
  ...props
}: AnimatedPathProps) {
  const anim = useRef(new Animated.Value(0)).current;
  const prevDRef = useRef<string>();
  const pathRef = useRef<Path>(null);

  useEffect(() => {
    // ...
    // 2.1.2. 에서 구현한 애니메이션 코드
    // ...
  }, [d]);

  const firstRef = useRef(true);
  const visibleAnim = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    if (firstRef.current) {
      firstRef.current = false;
      // `opacity` prop 은 <Path /> 에 직접 0 이라고 지정해도 되지만
      // 일단 2.1.2. 에서 d 값을 취급할 때와 같은 형식으로 해보자.
      pathRef.current?.setNativeProps({ opacity: 0 } as any);
    }

    // `addListener` 로 등록한 함수가 anim 의 값이 바뀔 때마다 실행된다.
    const listenerId = visibleAnim.addListener(({ value }) => {
      // `strokeDasharray` 를 사용해 애니메이션을 하려면 현재 path 의 길이를 알아야 한다.
      // 다행히 `<Path />` 컴포넌트에서 해당 값을 위한 메서드 `getTotalLength()` 를 제공한다.
      const pathLength = pathRef.current?.getTotalLength() ?? 1;
      // `strokeDasharray`, `strokeDashoffset`, `opacity` 를 사용해 애니메이션을 구현한다.
      pathRef.current?.setNativeProps({
        strokeDasharray: [pathLength, pathLength],
        strokeDashoffset: pathLength * (visible ? 1 - value : value - 1),
        opacity: Math.min(value * 5, 1),
      } as any);
    });

    // 애니메이션을 시작하는 코드는 일반적인 `Animated` 사용과 동일하다.
    const animated = Animated.timing(visibleAnim, {
      toValue: visible ? 1 : 0,
      duration: duration * 1.5,
      useNativeDriver: true,
    });

    animated.start(() => {
      visibleAnim.removeListener(listenerId);
      // 애니메이션이 끝나면 `strokeDasharray`, `strokeDashoffset` 값을 초기화한다.
      // 바로 윗줄에서 리스너를 제거했기 때문에 이렇게 해도 아무 일도 일어나지 않는다.
      // 이 값들이 설정되어 있으면 `d` 값이 변경되었을 때 라인이 이상하게 그려지므로 초기화 한 것이다.
      pathRef.current?.setNativeProps({
        strokeDasharray: null,
        strokeDashoffset: 0,
      } as any);
    });

    return () => animated.stop();
  }, [visible]);

  return <Path ref={pathRef} {...props} />;
}

export default AnimatedPath;

이제 등장/퇴장/값변경 시 라인의 애니메이션이 잘 동작하는 걸 확인할 수 있다.

2.1.4. 코드 중복 제거

구현을 하고 보니 "2.1.2. 데이터 변경 시 애니메이션 구현" 과 "2.1.3. 등/퇴장 시 애니메이션 구현" 에서 사용한 코드의 형식이 거의 같아보인다. X축/Y축 애니메이션에서도 같은 형식의 코드를 사용할 것으로 보이니, 이것을 하나의 커스텀 훅으로 만들어보자.

comps/LineChart/useAnimWithDelta.ts
import { useEffect, useRef } from "react";
import { Animated } from "react-native";

type UseSvgCompAnimOptions<T> = {
  initialValue?: T;
  onFirst?: (value: T) => void;
  onEnd?: () => void;
  duration?: number;
};
type DeltaListener<T> = (
  prev: NonNullable<T>,
  current: NonNullable<T>,
  delta: number
) => void;

function useAnimWithDelta<T>(
  value: T,
  onChangeDelta: DeltaListener<T>,
  options: UseSvgCompAnimOptions<T> = {}
) {
  const firstRef = useRef(true);
  const prevValueRef = useRef<T | undefined>(options.initialValue);
  const anim = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    const prevValue = prevValueRef.current;
    prevValueRef.current = value;

    if (firstRef.current) {
      firstRef.current = false;
      options.onFirst?.(value);
    }

    if (
      prevValue === undefined ||
      prevValue === null ||
      value === undefined ||
      value === null
    ) {
      return;
    }

    if (prevValue === value) {
      onChangeDelta(prevValue, value, 1);
      return;
    }

    const listener = (delta: number) => onChangeDelta(prevValue, value, delta);

    const listenerId = anim.addListener(({ value: delta }) => {
      listener(delta);
    });

    const animated = Animated.timing(anim, {
      toValue: 1,
      duration: options.duration ?? 300,
      useNativeDriver: true,
    });

    animated.start(() => {
      anim.removeListener(listenerId);
      anim.setValue(0);
      options.onEnd?.();
    });

    return () => animated.stop();
  }, [value]);
}

export default useAnimWithDelta;

좋다. 그리고 이 훅을 사용하도록 <AnimatedPath /> 를 수정하자.

comps/LineChart/Path/AnimatedPath.tsx
// ...

function AnimatedPath(/* ... */) {
  const pathRef = useRef<Path>(null);

  useAnimWithDelta(
    d,
    (prev, current, delta) => {
      const interpolate = interpolater(prev, current);
      pathRef.current?.setNativeProps({ d: interpolate(delta) } as any);
    },
    { initialValue: d }
  );

  useAnimWithDelta(
    visible,
    (_, current, delta) => {
      const pathLength = pathRef.current?.getTotalLength() ?? 1;
      pathRef.current?.setNativeProps({
        strokeDasharray: [pathLength, pathLength],
        strokeDashoffset: pathLength * (current ? 1 - delta : -delta),
        opacity: Math.min((current ? delta : 1 - delta) * 5, 1),
      } as any);
    },
    {
      duration: duration * 1.5,
      initialValue: false,
      onFirst: () => {
        pathRef.current?.setNativeProps({ opacity: 0 } as any);
      },
      onEnd: () => {
        pathRef.current?.setNativeProps({
          strokeDasharray: null,
          strokeDashoffset: 0,
        } as any);
      },
    }
  );

  return <Path ref={pathRef} opacity={0} {...props} />;
}

export default AnimatedPath;

2.2. X축에 애니메이션 적용

이제 X축에 애니메이션을 적용해보자.

등장은 간단하게 opacity 로만 구현하고, X 축의 범위가 달라질 경우에는 틱과 틱라벨, 수직 그리드라인에 트랜지션 애니메이션을 적용할 것이다.

2.2.1. X축 틱 애니메이션 구현

2.1. 의 <Path /> 컴포넌트가 내부적으로 애니메이션이 활성화 되었을 때와 비활성화 되었을 때 각각에 별개의 컴포넌트를 사용했던 것처럼, X축 틱 컴포넌트 <XTick /> 도 같은 방식으로 구현할 것이다.

comps/LineChart/LineChartBody/XAxis/XTick/index.tsx
import StaticXTick, { StaticXTickProps } from "./StaticXTick";

type XTickProps = Omit<StaticXTickProps, "x"> & {
  x: NonNullable<StaticXTickProps["x"]>;
  animatable: boolean;
  duration: number;
};
function XTick({ animatable, duration, ...props }: XTickProps) {
  if (!animatable) {
    return <StaticXTick {...props} />;
  }

  return <AnimatedXTick duration={duration} {...props} />;
}

export default XTick;

여기서 <StaticXTick /> 은 단순히 틱과 틱 라벨을 렌더링하는 컴포넌트다.

comps/LineChart/LineChartBody/XAxis/XTick/StaticXTick.tsx
import { G, Line, Text } from "react-native-svg";
import { LinearAxisOptions } from "../../../types";

export type StaticXTickProps = {
  x?: number;
  y: number;
  lineColor: string;
  lineWidth: number;
  labelColor: string;
  labelSize?: number;
  labelFont?: string;
  labelWeight?: LinearAxisOptions["tickLabelWeight"];
  label: string;
};
function StaticXTick({
  x,
  y,
  lineColor,
  lineWidth,
  labelColor,
  labelSize,
  labelFont,
  labelWeight,
  label,
}: StaticXTickProps) {
  return (
    <>
      <Line x={x} y2={y} stroke={lineColor} strokeWidth={lineWidth} />
      <Text
        x={x}
        y={y + 2}
        fill={labelColor}
        fontSize={labelSize}
        fontFamily={labelFont}
        fontWeight={labelWeight}
        textAnchor="middle"
        alignmentBaseline="hanging"
      >
        {label}
      </Text>
    </>
  );
}

export default StaticXTick;

<AnimatedXTick /><StaticXTick /> 과 2.1.4 에서 구현한 useAnimWithDelta 의 조합으로 구현하면 된다.

comps/LineChart/LineChartBody/XAxis/XTick/AnimatedXTick.tsx
import { useRef } from "react";
import { G } from "react-native-svg";
import useAnimWithDelta from "../../../useAnimWithDelta";
import StaticXTick, { StaticXTickProps } from "./StaticXTick";

type OnAnimListener<T> = (prev: T, current: T, delta: number) => number;
type AnimatedXTickProps = StaticXTickProps & {
  x: number;
  y?: number;
  visible?: boolean;
  duration?: number;
  onAnimX: OnAnimListener<number>;
  onAnimVisible: OnAnimListener<boolean>;
  children: ReactNode;
};
function AnimatedXTick({
  x,
  y,
  visible = true,
  onAnimX,
  onAnimVisible,
  duration = 300,
  ...props
}: AnimatedXTickProps) {
  const gRef = useRef<G<any>>(null);

  useAnimWithDelta(
    x,
    (prev, current, delta) => {
      gRef.current?.setNativeProps({ x: onAnimX(prev, current, delta) });
    },
    {
      duration,
      onFirst: () => {
        gRef.current?.setNativeProps({ x });
      },
    }
  );

  useAnimWithDelta(
    visible,
    (prev, current, delta) => {
      gRef.current?.setNativeProps({
        opacity: onAnimVisible(prev, current, delta),
      } as any);
    },
    {
      duration,
      initialValue: false,
      onFirst: () => {
        gRef.current?.setNativeProps({ opacity: 0 } as any);
      },
    }
  );

  return (
    <G ref={gRef} y={y}>
      <StaticXTick {...props} />
    </G>
  );
}

export default AnimatedXTick;

그런데 구현해놓고 생각해보니 X축 틱과 X축 그리드라인의 애니메이션은 구현이 동일할 것이라는 생각이 든다. (등/퇴장은 opacity 로만, X축 범위가 변할 때는 이동만) 그러니 여기에서 애니메이션 기능만 분리해서 재활용할 수 있게 하면 좋겠다는 생각이 든다. 분리해보자.

방법은 간단하다. <AnimatedXTick /> 의 이름을 (TransitionX 로) 변경하고, 내부적으로 <StaticXTick /> 을 사용한 부분을 children 을 받아 사용하게끔 고쳐주면 된다.

comps/LineChart/TransitionX.tsx
// ...

type TransitionXProps = {
  // ...
};
function TransitionX({
  // ...
}: TransitionXProps) {
  const gRef = useRef<G<any>>(null);

  useAnimWithDelta(
    // ...
  );

  useAnimWithDelta(
    // ...
  );

  return (
    <G ref={gRef} y={y}>
      {children}
    </G>
  );
}

export default TransitionX;

그러면 <TransitionX /> 를 사용하도록 <XTick /> 을 수정하자.

comps/LineChart/LineChartBody/XAxis/XTick/index.tsx
// ...

function XTick(/* ... */) {
  if (!animatable) {
    return <StaticXTick x={x} {...props} />;
  }

  return (
    <TransitionX
      x={x}
      onAnimX={(prev, current, delta) => (current - prev) * delta + prev}
      onAnimVisible={(_, current, delta) => (current ? delta : 1 - delta)}
      duration={duration}
    >
      <StaticXTick {...props} />
    </TransitionX>
  );
}

export default XTick;

(<AnimatedXTick /> 은 필요없어졌다. 지워버리자.)

마지막으로 이 <XTick /> 을 사용하도록 <XAxis /> 를 수정하자.

comps/LineChart/LineChartBody/XAxis/index.tsx
// ...

function XAxis(/* ... */) {
  const ticks = !_ticks
    ? scale.ticks()
    : typeof _ticks === "number"
    ? scale.ticks(_ticks)
    : typeof _ticks === "function"
    ? _ticks(scale)
    : _ticks;

  //...

  return (
    <>
      <G x={x} y={y}>
        <Line
          x1={range[0]}
          x2={range[1]}
          stroke={lineColor}
          strokeWidth={lineWidth}
        />
        {showTicks &&
          ticks.map((tick) => (
            <XTick
              key={tickLabelFormatter(tick)}
              x={scale(tick)}
              y={tickLength}
              lineColor={tickColor}
              lineWidth={tickWidth}
              labelColor={tickLabelColor}
              labelSize={tickLabelSize}
              labelFont={tickLabelFont}
              labelWeight={tickLabelWeight}
              label={tickLabelFormatter(tick)}
              animatable={animatable}
              duration={animDuration}
            />
          ))}
      </G>
      {/* ... */}
    </>
  );
}

export default XAxis;

2.2.2. X축 그리드라인 애니메이션 구현

X축의 수직 그리드라인에도 애니메이션을 적용해보자. <XTick /> 과 동일한 방식으로 <VerticalLine /> 컴포넌트를 구현하자.

comps/LineChart/LineChartBody/XAxis/VerticalLine/index.tsx
import StaticVerticalLine, {
  StaticVerticalLineProps,
} from "./StaticVerticalLine";
import TransitionX from "../../../TransitionX";

type VerticalLineProps = Omit<StaticVerticalLineProps, "x"> & {
  x: NonNullable<StaticVerticalLineProps["x"]>;
  animatable: boolean;
  duration: number;
};
function VerticalLine({
  x,
  animatable,
  duration,
  ...props
}: VerticalLineProps) {
  if (!animatable) {
    return <StaticVerticalLine x={x} {...props} />;
  }

  return (
    <TransitionX
      x={x}
      onAnimX={(prev, current, delta) => (current - prev) * delta + prev}
      onAnimVisible={(_, current, delta) => (current ? delta : 1 - delta)}
      duration={duration}
    >
      <StaticVerticalLine {...props} />
    </TransitionX>
  );
}

export default VerticalLine;

바로 이전 절에서 구현했던 <TransitionX /> 컴포넌트를 애니메이션에 활용한 것을 볼 수 있다.

<StaticVerticalLine /> 컴포넌트는 <Line /> 컴포넌트만 사용한 아주 간단한 컴포넌트다. 사실 <Line /> 을 직접 사용했어도 큰 문제가 없었겠지만 <XTick /> 과의 통일성과 prop 반복을 줄이기 위해 만들었다.

comps/LineChart/LineChartBody/XAxis/VerticalLine/StaticVerticalLine.tsx
import { Line } from "react-native-svg";

export type StaticVerticalLineProps = {
  x?: number;
  y1: number;
  y2: number;
  lineColor: string;
  lineWidth?: number;
};
function StaticVerticalLine({
  x,
  y1,
  y2,
  lineColor,
  lineWidth,
}: StaticVerticalLineProps) {
  return (
    <Line
      x1={x}
      x2={x}
      y1={y1}
      y2={y2}
      stroke={lineColor}
      strokeWidth={lineWidth}
    />
  );
}

export default StaticVerticalLine;

이제 마지막으로 <XAxis /> 에서 이 <VerticalLine /> 컴포넌트를 사용하도록 수정하자.

comps/LineChart/LineChartBody/XAxis/index.tsx
// ...

function XAxis(/* ... */) {
  const ticks = !_ticks
    ? scale.ticks()
    : typeof _ticks === "number"
    ? scale.ticks(_ticks)
    : typeof _ticks === "function"
    ? _ticks(scale)
    : _ticks;

  //...

  return (
    <>
      {/* ... */}
      <G>
        {showGridLines &&
          ticks.map((tick) => (
            <VerticalLine
              key={`${tick}`}
              x={scale(tick)}
              y1={paneBoundary.y1}
              y2={paneBoundary.y2}
              lineColor={gridLineColor}
              lineWidth={gridLineWidth}
              visible={!old}
              animatable={animatable}
              duration={animDuration}
            />
          ))}
      </G>
    </>
  );
}

export default XAxis;

2.2.3. 퇴장 애니메이션 개선

거의 다 되었다. 그런데 현재 코드로는 퇴장 애니메이션을 사용할 수 없다. <XAxis />ticks 이 변경되는 순간, 이제 필요 없어진 tick 들이 퇴장 애니메이션을 적용할 새도 없이 없어져버리기 때문이다.

불필요한 tick 데이터가 퇴장 애니메이션이 적용된 후에 삭제되도록 처리를 해주어야 한다.

여기서는 ticks 를 state 로 관리하면서, 불필요해진 tick 은 플래그만 바꿔서 저장하고 있다가 퇴장 애니메이션이 끝나면 데이터를 삭제하도록 수정할 것이다.

comps/LineChart/LineChartBody/XAxis/index.tsx
// ...

function XAxis({
  // ...
  scale,
  ticks: _ticks,
}: XAxisProps) {
  // ...

  const [state, setState] = useImmer({
    ticks: [] as { value: Date; old: boolean }[],
  });

  useEffect(() => {
    // props 으로 받은 `_tick` 이 변결될 때마다 틱들을 다시 생성한다.
    const ticks = !_ticks
      ? scale.ticks()
      : typeof _ticks === "number"
      ? scale.ticks(_ticks)
      : typeof _ticks === "function"
      ? _ticks(scale)
      : _ticks;

    setState((dr) => {
      // 이전에는 틱이었으나 새로운 `_tick` 에서는 제외된 틱들을 `oldTicks` 에 모은다.
      // 이 때 `old` 플래그를 `true` 로 해준다.
      const oldTicks = dr.ticks
        .filter((it) => !ticks.find((tick) => `${tick}` === `${it.value}`))
        .map((it) => ({ ...it, old: true }));

      // 새로운 틱을 `state.ticks` 에 저장한다.
      dr.ticks = [...ticks.map((value) => ({ value, old: false }))];
      if (animatable) {
        // 애니메이션이 활성화되어 있다면 `state.ticks` 에 `oldTicks` 를 포함시킨다.
        dr.ticks = [...oldTicks, ...dr.ticks];
      }
    });

    if (animatable) {
      // 애니메이션 시간이 끝나면 `old` 플래그가 `true` 였던 값을 `state.ticks` 에서 빼준다.
      // 퇴장 애니메이션이 끝났기 때문에 갖고 있을 필요가 없다.
      setTimeout(() => {
        setState((dr) => {
          dr.ticks = dr.ticks.filter((it) => !it.old);
        });
      }, animDuration);
    }
  }, [_ticks, scale]);

  if (!enabled) {
    return null;
  }
  return (
    <>
      <G x={x} y={y}>
        {/* ... */}
        {showTicks &&
          state.ticks.map(({ value, old }) => (
            <XTick
              key={tickLabelFormatter(value)}
              x={scale(value)}
              // `visible` 필드에 `!old` 플래그를 넣어준다.
              // `false` 라면 퇴장 애니메이션을 시작할 것이다.
              visible={!old}
              // ...
            />
          ))}
      </G>
      <G>
        {showGridLines &&
          state.ticks.map(({ value, old }) => (
            <VerticalLine
              key={`${value}`}
              x={scale(value)}
              // `visible` 필드에 `!old` 플래그를 넣어준다.
              // `false` 라면 퇴장 애니메이션을 시작할 것이다.
              visible={!old}
              // ...
            />
          ))}
      </G>
    </>
  );
}

export default XAxis;

마지막으로 <XTick /><VerticalLine />visible prop 을 받아 <TransitionX /> 에 전달해주도록 수정해주자.

comps/LineChart/LineChartBody/XAxis/XTick/index.tsx
import StaticXTick, { StaticXTickProps } from "./StaticXTick";
import TransitionX from "../../../TransitionX";

type XTickProps = Omit<StaticXTickProps, "x"> & {
  x: NonNullable<StaticXTickProps["x"]>;
  visible: boolean;  animatable: boolean;
  duration: number;
};
function XTick({ x, visible, animatable, duration, ...props }: XTickProps) {  if (!animatable) {
    return <StaticXTick x={x} {...props} />;
  }

  return (
    <TransitionX
      x={x}
      visible={visible}      onAnimX={(prev, current, delta) => (current - prev) * delta + prev}
      onAnimVisible={(_, current, delta) => (current ? delta : 1 - delta)}
      duration={duration}
    >
      <StaticXTick {...props} />
    </TransitionX>
  );
}

export default XTick;
comps/LineChart/LineChartBody/XAxis/VerticalLine/index.tsx
import StaticVerticalLine, {
  StaticVerticalLineProps,
} from "./StaticVerticalLine";
import TransitionX from "../../../TransitionX";

type VerticalLineProps = Omit<StaticVerticalLineProps, "x"> & {
  x: NonNullable<StaticVerticalLineProps["x"]>;
  visible: boolean;  animatable: boolean;
  duration: number;
};
function VerticalLine({
  x,
  visible,  animatable,
  duration,
  ...props
}: VerticalLineProps) {
  if (!animatable) {
    return <StaticVerticalLine x={x} {...props} />;
  }

  return (
    <TransitionX
      x={x}
      visible={visible}      onAnimX={(prev, current, delta) => (current - prev) * delta + prev}
      onAnimVisible={(_, current, delta) => (current ? delta : 1 - delta)}
      duration={duration}
    >
      <StaticVerticalLine {...props} />
    </TransitionX>
  );
}

export default VerticalLine;

이제 X축의 애니메이션 구현이 완료되었다.

2.3. Y축에 애니메이션 적용

Y축 애니메이션에 대한 구현은 X축 애니메이션 구현과 99% 동일하다. 따라서 해당 구현에 대한 설명은 생략하겠다. 자세한 사항은 3.2. 를 참고하자.

3. 결과

이제 차트에서 애니메이션이 잘 동작하는 걸 확인할 수 있다.

3.1. 이슈

기능 자체는 잘 동작하지만 이슈가 몇 개 남아있다.

  • 성능 이슈가 있다. 동시에 애니메이션하는 요소들이 많으면 많을 수록 애니메이션이 더 끊기는 걸 볼 수 있다. 한 화면에 여러 개의 차트를 렌더링했을 경우에도 마찬가지다.
  • 아이템 선택 및 툴팁 기능에는 애니메이션이 적용되지 않았다. 선택된 상태로 시리즈를 토글하면 부자연스럽게 이동하는 걸 볼 수 있다.

3.2. 상세 코드 전문

본문에 삽입된 코드들은 생략된 부분이 많이 있다. 코드 전문을 보고 싶다면 아래 링크를 참고하자.

3.3. 실행해보기

구현 결과를 실기기 혹은 에뮬레이터에서 직접 확인하고 싶다면 https://github.com/ricale/D3ChartExamples 에서 프로젝트를 받아서 실행해보면 된다.

# 프로젝트 코드 다운로드
git clone https://github.com/ricale/D3ChartExamples.git
cd ./D3ChartExamples
# 안드로이드 실행
yarn
yarn android
# iOS 실행
yarn
cd ./ios && pod install && cd ../
yarn ios

4. 다음

다음에 구현 및 정리할 예정인 작업들은 아래와 같다.

  1. 컬럼차트 구현
  2. 파이차트 구현

컬럼차트는 라인차트에서 구현했던 내용들을 최대한 재활용할 계획이므로 글이 그다지 많지도 길지도 않을 것으로 예상된다. 하지만 파이차트는 라인차트와 겹치는 부분이 많지 않으므로, 어떻게 될지 모르겠다.