React Native 에서 차트 구현 - 5. 라인차트 애니메이션 기능
1. 개요
이전 글까지는 아래와 같은 작업들을 진행했다.
오늘은 애니메이션 기능을 구현해보자.
2. 구현
애니메이션을 적용해야 할 곳은 두 군데가 있다.
- 데이터를 표시하는 라인들
- X 축, Y 축, 그리드라인
차례대로 적용해보자.
2.1. 라인에 애니메이션 적용
라인차트의 라인들은 <Lines />
컴포넌트로 구현되어 있다. 해당 코드를 잠깐 살펴보면 아래와 같다.
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 />
컴포넌트를 한 번 래핑해서 각각의 애니메이션을 관리하도록 하겠다.
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
를 직접 사용하지 않는 다른 방법을 찾아야 한다. 따라서 우리는 Animated
와 setNativeProps()
를 조합해서 애니메이션을 구현할 것이다.
setNativeProps()
는 컴포넌트의 prop 을 직접 수정할 수 있도록 RN 측에서 제공하는 함수다. 성능 이슈가 있으므로 일반적인 상황에서는 사용을 권장하지 않는다.
그럼 한 번 사용해보자.
2.1.2. 데이터 변경 시 애니메이션 구현
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
을 렌더링하고 있는데
{series.map((sr, i) =>
!sr.visible ? null : (
<Path
이래서야 퇴장 애니메이션을 보여줄 수 없다. 퇴장 애니메이션을 보여줄 수 있도록 조금만 수정하자.
// ...
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 />
에 애니메이션을 구현하자.
// ...
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축 애니메이션에서도 같은 형식의 코드를 사용할 것으로 보이니, 이것을 하나의 커스텀 훅으로 만들어보자.
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 />
를 수정하자.
// ...
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 />
도 같은 방식으로 구현할 것이다.
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 />
은 단순히 틱과 틱 라벨을 렌더링하는 컴포넌트다.
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
의 조합으로 구현하면 된다.
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
을 받아 사용하게끔 고쳐주면 된다.
// ...
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 />
을 수정하자.
// ...
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 />
를 수정하자.
// ...
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 />
컴포넌트를 구현하자.
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 반복을 줄이기 위해 만들었다.
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 />
컴포넌트를 사용하도록 수정하자.
// ...
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
은 플래그만 바꿔서 저장하고 있다가 퇴장 애니메이션이 끝나면 데이터를 삭제하도록 수정할 것이다.
// ...
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 />
에 전달해주도록 수정해주자.
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;
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. 다음
다음에 구현 및 정리할 예정인 작업들은 아래와 같다.
- 컬럼차트 구현
- 파이차트 구현
컬럼차트는 라인차트에서 구현했던 내용들을 최대한 재활용할 계획이므로 글이 그다지 많지도 길지도 않을 것으로 예상된다. 하지만 파이차트는 라인차트와 겹치는 부분이 많지 않으므로, 어떻게 될지 모르겠다.