🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!
🗒️ 이 글의 수정 내역 (마지막 수정 일자: 23.03.27)
initializeMetaballs
의 분기처리를 수정했어요. (else if
→if
)
혹시나 따라오는 데 오류가 있으시다면, 위 URL의 레포지토리의
PR
및 코드들을 살펴봐주세요!
자, 이제 본격적으로 캔버스 애니메이션을 동작시켜볼 거에요.
일단 먼저 window.requestAnimationFrame
을 알아보아야 합니다.
이 함수는 말 그대로 프레임을 요청합니다. 그리고 이를 호출하면, 1프레임을 실행한 상태 변경을 만들어내죠.
이 글은 메타볼 애니메이션 구현과 리팩토링하는 것이 주가 되는 글이기에, 설명은 생략하겠습니다. 자세한 것은 MDN - Window.requestAnimationFrame()을 참고해주세요.
우리는 이것을 캔버스에서 실행함으로써, 애니메이션을 구동할 거에요.
다음과 같이 2번 시리즈에서 만들어낸 Canvas
클래스를 수정해주세요.
간단히 설명 드리자면 mount
와 render
, animate
코드가 중요합니다.
mount($target: Element) {
$target.appendChild(this.$canvas);
this.render();
if (!this.options?.pause && this.options?.autoplay) {
this.animate();
}
}
render() {
this.draw(this.canvasGradient);
this.metaballAnimationSubject.notify();
}
animate() {
if (this.options?.pause) return;
this.render();
requestAnimationFrame(this.animate.bind(this));
설정된 옵션에 따라서 mount
render
animate
를 진행해주는데요.
이때, animate
안에는 또 requestAnimationFrame
의 콜백으로 같은 함수가 호출됩니다.
이를 통해, 재귀적으로 실행하며 무한하게 애니메이션을 호출할 수 있는 거에요.
그런데 이것이 싫을 수 있잖아요?! 그럴 때를 대비하여 메타 정보로 options
에 pause
를 추가해주었어요. 이렇게 하면 애니메이션은 원할 때 해당 property
를 변경하여 끌 수 있겠죠. 😉
잘 동작하는지 metaballs의 moveAll
메서드에 콘솔을 넣어 확인해보죠!
export class StaticMetaballs implements Metaballs<StaticMetaball> {
// ...
moveAll() {
/* eslint-disable-next-line no-console */
console.log('moveAll!');
}
}
export class DynamicMetaballs implements Metaballs<DynamicMetaball> {
// ...
moveAll() {
/* eslint-disable-next-line no-console */
console.log('moveAll!');
}
}
윽! 아직 동작하지 않죠? 😖
정상입니다. 아직 메타볼에 대한 초기값을 넣어주지 않았기 때문이에요.
자 그러면 이제 메타볼의 속성들을 세팅해줘서 정상적으로 동작하는지를 보겠습니다! 🙆🏻
먼저 App.ts
에 다음과 같이 맨 아래에 코드를 넣어 호출해볼게요.
const app = new MetaballAnimation({
canvas: new MetaballCanvas({
gradients: ['#123141', '#235234'],
width: 400,
height: 400,
type: ECanvasGradientType.linear,
options: {
autoplay: true,
},
}),
dataset: {
static: [{x: 30, y: 100, r: 20}],
dynamic: [
{
x: 30,
y: 100,
r: 20,
v: {x: 0.1, y: 0.1},
vWeight: 1,
},
],
},
});
app.mount($target);
여기서 dataset
이라는 property가 추가되었네요. 이 친구가 세팅되는 순간, static
과 dynamic
메타볼들이 만들도록 호출될 거에요.
사실 Canvas
라는 걸 생성할 때 만들어줄까 생각해보았어요.
하지만 가장 구체적인 클래스가 데이터를 가지고, 하위 클래스들에게 메서드를 통해 전달하는 것이 하위 클래스와 데이터에 대한 결합성을 낮추고, 좀 더 유연하게 가져갈 수 있다고 생각하여 다음과 같이 설계했습니다. 😉
그러면 이를 동작시키기 위한 코드를 구현하면 되겠죠?
나머지를 입력해주죠.
export class MetaballAnimation implements CanvasAnimation {
public canvas: CanvasAnimation['canvas'];
public dataset: IMetaballDataset;
constructor({
canvas,
dataset,
}: {
canvas: CanvasAnimation['canvas'];
dataset?: IMetaballDataset;
}) {
this.canvas = canvas;
this.dataset = dataset ?? {
static: [],
dynamic: [],
};
if (this.dataset?.static?.length || this.dataset?.dynamic?.length) {
this.initializeMetaballs(this.dataset);
}
}
initializeMetaballs(dataset: IMetaballDataset) {
this.canvas.initializeMetaballs(dataset);
}
// ... 생략
}
자. 그러면 우리는 initializeMetballs
라는 메서드를 options
가 시작할 때 데이터가 들어와 있으면 호출해주죠?!
이제 canvas
에서는 어떻게 써야 할까요? 한 번 살펴봅시다.
// Canvas.ts
export class MetaballCanvas implements GradientCanvas {
$canvas: GradientCanvas['$canvas'];
ctx: GradientCanvas['ctx'];
type: GradientCanvas['type'];
width: GradientCanvas['width'];
height: GradientCanvas['height'];
gradients: GradientCanvas['gradients'];
metaballAnimationSubject: AnimationSubject;
staticMetaballsFactory: StaticMetaballsFactory;
dynamicMetaballsFactory: DynamicMetaballsFactory;
options: GradientCanvas['options'];
constructor({
type,
width,
height,
gradients,
options,
}: Omit<
GradientCanvas,
| '$canvas'
| 'ctx'
| 'render'
| 'mount'
| 'draw'
| 'getLinearGradient'
| 'getRadialGradient'
>) {
this.$canvas = document.createElement('canvas');
this.ctx = this.$canvas.getContext('2d') as CanvasRenderingContext2D;
this.type = type;
this.width = width;
this.height = height;
this.gradients = gradients;
this.metaballAnimationSubject = new AnimationSubject({ctx: this.ctx});
this.staticMetaballsFactory = new StaticMetaballsFactory();
this.dynamicMetaballsFactory = new DynamicMetaballsFactory();
this.options = options ?? {
radialGradient: {r0: 0, r1: 0},
autoplay: false,
pause: false,
};
}
initializeMetaballs(dataset: IMetaballDataset) {
if (dataset.static) {
const staticMetaballs = this.staticMetaballsFactory.create({
options: {
ctx: this.ctx,
data: dataset.static,
},
});
this.metaballAnimationSubject.subscribe(
new StaticMetaballsObserver(staticMetaballs, 'static'),
);
}
if (dataset.dynamic) {
const dynamicMetaballs = this.dynamicMetaballsFactory.create({
options: {
ctx: this.ctx,
data: dataset.dynamic,
},
});
this.metaballAnimationSubject.subscribe(
new DynamicMetaballsObserver(dynamicMetaballs, 'dynamic'),
);
}
}
// ...
}
이제 여기서 Factory
들을 쓰게 되는군요!
이 친구들을 덕분에 일련의 생성 로직들을 생각하지 않고 팩토리 메서드만으로 만들어내니 굉장히 코드가 간결해졌죠?
이후 subscribe
내부에서 옵저버를 생성함과 동시에 달아주는군요! 어썸합니다 🚀
자. 그러면 이제 결과를 볼까요?
오! 잘 작동되는군요.
아무래도 긴 글이다 보니 따라오기 쉽지 않았을 것 같아요.
결과 코드를 공유 드리며, 이마저도 안된다면, commit을 살펴봐주세요!
Canvas
export class MetaballCanvas implements GradientCanvas {
$canvas: GradientCanvas['$canvas'];
ctx: GradientCanvas['ctx'];
type: GradientCanvas['type'];
width: GradientCanvas['width'];
height: GradientCanvas['height'];
gradients: GradientCanvas['gradients'];
metaballAnimationSubject: AnimationSubject;
staticMetaballsFactory: StaticMetaballsFactory;
dynamicMetaballsFactory: DynamicMetaballsFactory;
options: GradientCanvas['options'];
constructor({
type,
width,
height,
gradients,
options,
}: Omit<
GradientCanvas,
| '$canvas'
| 'ctx'
| 'render'
| 'mount'
| 'draw'
| 'getLinearGradient'
| 'getRadialGradient'
>) {
this.$canvas = document.createElement('canvas');
this.ctx = this.$canvas.getContext('2d') as CanvasRenderingContext2D;
this.type = type;
this.width = width;
this.height = height;
this.gradients = gradients;
this.metaballAnimationSubject = new AnimationSubject({ctx: this.ctx});
this.staticMetaballsFactory = new StaticMetaballsFactory();
this.dynamicMetaballsFactory = new DynamicMetaballsFactory();
this.options = options ?? {
radialGradient: {r0: 0, r1: 0},
autoplay: false,
pause: false,
};
}
initializeMetaballs(dataset: IMetaballDataset) {
if (dataset.static) {
const staticMetaballs = this.staticMetaballsFactory.create({
options: {
ctx: this.ctx,
data: dataset.static,
},
});
this.metaballAnimationSubject.subscribe(
new StaticMetaballsObserver(staticMetaballs, 'static'),
);
}
if (dataset.dynamic) {
const dynamicMetaballs = this.dynamicMetaballsFactory.create({
options: {
ctx: this.ctx,
data: dataset.dynamic,
},
});
this.metaballAnimationSubject.subscribe(
new DynamicMetaballsObserver(dynamicMetaballs, 'dynamic'),
);
}
}
getLinearGradient() {
const result = this.ctx.createLinearGradient(0, 0, 0, this.height);
this.gradients.forEach((gradient, idx) => {
result.addColorStop(idx, gradient);
});
return result;
}
getRadialGradient({r0 = 0, r1 = 0}: IRadialGradientOptions) {
const result = this.ctx.createRadialGradient(0, 0, r0, 0, this.height, r1);
this.gradients.forEach((gradient, idx) => {
result.addColorStop(idx, gradient);
});
return result;
}
get canvasGradient(): CanvasGradient {
switch (this.type) {
case ECanvasGradientType.linear: {
return this.getLinearGradient();
}
case ECanvasGradientType.radial: {
return this.getRadialGradient(
this.options?.radialGradient ?? {r0: 0, r1: 0},
);
}
default: {
return this.getLinearGradient();
}
}
}
draw(background: CanvasGradient) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.ctx.fillStyle = background;
this.ctx.fillRect(0, 0, this.width, this.height);
}
mount($target: Element) {
$target.appendChild(this.$canvas);
this.render();
if (!this.options?.pause && this.options?.autoplay) {
this.animate();
}
}
render() {
this.draw(this.canvasGradient);
this.metaballAnimationSubject.notify();
}
animate() {
if (this.options?.pause) return;
this.render();
requestAnimationFrame(this.animate.bind(this));
}
}
import {MetaballCanvas} from './Canvas';
import {ECanvasGradientType, IMetaballDataset} from './types';
export abstract class CanvasAnimation {
abstract canvas: MetaballCanvas;
abstract mount($target: Element): void;
abstract render(): void;
}
export class MetaballAnimation implements CanvasAnimation {
public canvas: CanvasAnimation['canvas'];
public dataset: IMetaballDataset;
constructor({
canvas,
dataset,
}: {
canvas: CanvasAnimation['canvas'];
dataset?: IMetaballDataset;
}) {
this.canvas = canvas;
this.dataset = dataset ?? {
static: [],
dynamic: [],
};
if (this.dataset?.static?.length || this.dataset?.dynamic?.length) {
this.initializeMetaballs(this.dataset);
}
}
initializeMetaballs(dataset: IMetaballDataset) {
this.canvas.initializeMetaballs(dataset);
}
mount($target: Element) {
const $metaballAnimation = document.createElement('div');
$metaballAnimation.className = 'metaball-animation';
$target.appendChild($metaballAnimation);
this.canvas.mount($metaballAnimation);
}
render() {
this.canvas.render();
}
}
const $target = document.body;
const app = new MetaballAnimation({
canvas: new MetaballCanvas({
gradients: ['#123141', '#235234'],
width: 400,
height: 400,
type: ECanvasGradientType.linear,
options: {
autoplay: true,
},
}),
dataset: {
static: [{x: 30, y: 100, r: 20}],
dynamic: [
{
x: 30,
y: 100,
r: 20,
v: {x: 0.1, y: 0.1},
vWeight: 1,
},
],
},
});
app.mount($target);
생각보다 깔끔한 설계란 어려운 것 같아요.
저 역시 이번에 리팩토링을 한 번 거칠 수밖에 없었습니다. 데이터를 전달하는 로직에 대한 착각들이 주였어요.
그렇기에 이번에 깨달은 건, data를 실제로 handle하는 상황을 미리 만든 다음, 이를 구현시킬 수 있도록 짜는 방식으로 해야겠다는 것을 깨달았어요.
그런데 정말 뿌듯한 경험을 했어요.
확실히 의존성을 느슨하게 결합하면서 디자인 패턴을 토대로 구현하니, 각 컴포넌트의 변경에 따라 다른 컴포넌트의 변경이 현저히 줄어들었다는 점입니다.
이게 정말 좋은 설계를 통해 만들어낸 코드의 매력이 아닐까요?
싶으며 혼자 만족하고 취하고(...) 이만 마칩니다. 🤣
이제 모든 설계는 끝났네요. 다음부터는 본격적으로 메타볼들을 만들어보죠! 이상 🌈