Programming/Vue.js

[Vue, canvas] Vue.js에서 Canvas 사용하기

stein 2021. 10. 30. 15:55

Canvas?

필자는 유튜브에서 Interactive Developer로 활동하시는 김종민님의 작품을 보면서 프론트의 canvas 기능의 존재를 처음 알게되었다. 그리고 프론트의 매력을 느껴 지금까지 프론트개발을 쭉 해오고있는데, 아이러니하게도 정작 canvas는 자주 다룰 일이 없었다.

 

프론트를 시작한지 1년이 다 되어가는 최근에야 다시 vanilla js를 사용하면서 canvas를 만져보는 중이다.

하지만 언제까지 vanilla에서만 놀것인가. 현재 진행중인 [개인 블로그 제작] 프로젝트를 하는 김에 canvas를 vue에서 편하게 사용하는 틀을 만들기로 했다. 자, 이제 내가 구현한 코드들을 살펴보자. Nuxt.js / Vue2 Composition-api를 사용했다.

(🚶‍♀️ 아직 canvas에 대한 이해도가 부족하다면 김종민님의 공튀기기 강좌를 추천한다)

(🏃‍♀️ 급하신 분들은 포스트 중반부인 [utils.js로 분리] 부터 코드를 참고하시라)


vue에 반응형 canvas 붙이기

우리의 최종 목표는 다음과 이미지와 같다.

반응형 canvas!

 

우선 canvas를 vue에서 그려보자

<template>
  <div style="width: 100%; height: 100%;padding: 20px 40px">
    <canvas id="circle-menu" style="width: 100%; height: 100%"></canvas>
  </div>
</template>

import { defineComponent, onMounted, ref } from '@vue/composition-api'
export default defineComponent({
  setup() {
    let ctx
    const canvasWidth = ref(0)
    const canvasHeight = ref(0)

    const getCanvas = () => {
      const canvas = document.getElementById('circle-menu')
      const ctx = canvas.getContext('2d')

      const rect = canvas.getBoundingClientRect()
      canvasWidth.value = rect.width
      canvasHeight.value = rect.height
      canvas.width = canvasWidth.value
      canvas.height = canvasHeight.value 

      return {canvas, ctx}
    }
    const drawCanvas = (ctx) => {
      ctx.beginPath()
      ctx.arc(canvasWidth.value/2, canvasHeight.value/2, canvasWidth.value/2 - 2, 0, Math.PI * 2, true) // Outer circle
      ctx.fillStyle = '#c4c4c4'
      ctx.fill()
      ctx.stroke()
    }
    const mounted = () => {
      const tempCanvas = getCanvas()
      ctx = tempCanvas.ctx

      drawCanvas(ctx)
      
    }
    onMounted(mounted)

    return {  }
  },
})
</script>

이 때 mounted된 후에 document가 생성되기 때문에, 모든 작업을 mounted뒤에 걸어주는 것만 주의하면 된다.

그 외에는 기존에 vanilla js에서 사용하는 것과 동일하게 사용하면된다.

 

아마 위의 코드는 정상적으로 그릴테지만...

끼욧

위와 같이 window size가 변한다면 찌부러지거나 그림이 뭉개지게 된다. 따라서 window size가 변하는 것을 감지하여서 그 때마다 새로이 canvas를 그려주어야한다. 

 

How can I use window size in Vue? (How do I detect the soft keyboard?)

In my mobile web app with Vue, I want to hide my footer when the soft keyboard pops. So I have a little function to test the ratio of window height to window width... showFooter(){ return h / ...

stackoverflow.com

필자는 위의 게시물을 참고하였는데 "The above answer didn't work for me. Instead, I used:" 라고 쓰여진 답변에 적힌 코드를 참고하였다. (그 앞의 것들은 저 작성자와 마찬가지로 원하는 대로 작동하지 않았다...삽질)

<template>
  <div style="width: 100%; height: 100%;padding: 20px 40px">
    <canvas id="circle-menu" style="width: 100%; height: 100%"></canvas>
  </div>
</template>

import { defineComponent, onMounted, ref } from '@vue/composition-api'
export default defineComponent({
  setup() {
    let ctx
    const canvasWidth = ref(0)
    const canvasHeight = ref(0)

    const getCanvas = () => {
      const canvas = document.getElementById('circle-menu')
      const ctx = canvas.getContext('2d')

      const rect = canvas.getBoundingClientRect()
      canvasWidth.value = rect.width
      canvasHeight.value = rect.height
      canvas.width = canvasWidth.value
      canvas.height = canvasHeight.value 

      window.addEventListener('resize', () => {
        const rect = canvas.getBoundingClientRect()
        canvasWidth.value = rect.width
        canvasHeight.value = rect.height
        canvas.width = canvasWidth.value
        canvas.height = canvasHeight.value 
        drawCanvas(canvas.getContext('2d'))
      })
      return {canvas, ctx}
    }
    const drawCanvas = (ctx) => {
      ctx.beginPath()
      ctx.arc(canvasWidth.value/2, canvasHeight.value/2, canvasWidth.value/2 - 2, 0, Math.PI * 2, true) // Outer circle
      ctx.fillStyle = '#c4c4c4'
      ctx.fill()
      ctx.stroke()
    }
    const mounted = () => {
      const tempCanvas = getCanvas()
      ctx = tempCanvas.ctx

      drawCanvas(ctx)
      
    }
    onMounted(mounted)

    return {  }
  },
})
</script>

window.addEventListener를 추가하였고 우리가 원하는대로 window size가 변경될 때 마다 새롭게 canvas를 그려준다.

 

하지만... 코드가 너무 더럽다. 

 

그리고 이대로면 코드를 재사용하는 것이 아닌, 복사/붙여넣기 할 것이 뻔하기 때문에 정리를 해보자. 


utils.js로 분리

아래와 같은 기~다란 코드가

덩어리 코드

다음과 같이 간결한 코드로 바뀌었다. (그냥 옮긴것 같아 보이는데...)

재사용성이 높은 class 구조로 변경

두 개 코드 합치면 그냥 원래 길이 아니냐!! 라고 하실 수도 있겠지만, 한 번 들어보시라.

const vueCanvas = new VueCanvas(canvas)
drawCircleMenu(vueCanvas.canvas)

vueCanvas.makeCanvasResponsive(() => {
	drawCircleMenu(vueCanvas.canvas)
})

직접 만든 VueCanvas 객체는 크게 3단계로 구분하여 사용할 수 있다.

 

1. (mount시) VueCanvas객체를 생성(constructor 작동)

2. (mount시) 최초로 canvas를 그리는 함수(위에서는 drawCircleMenu)

3. windowSize가 변경될 때를 설정하는 메소드와 함수(VueCanvas.makeCanvasResponsive, drawCircleMenu)

 

이렇게하면 template에 선언한 canvas가 바로 반응성을 가지면서, 코드도 분리하여서 vue component 내부도 간결하게 유지할 수 있다.

 

utils.js는 다음 2부분으로 이루어져있다.

1. VueCanvas class 정의 부분

export default class VueCanvas {
    constructor(canvas) {
        this.canvas = canvas
        this.ctx = canvas.getContext('2d')
        this.responseCanvasSize()
    }

    responseCanvasSize() {
        const rect = this.canvas.getBoundingClientRect()
        this.canvas.width = rect.width
        this.canvas.height = rect.height
    }

    makeCanvasResponsive(drawCanvas) {
        window.addEventListener('resize', () => {
            this.responseCanvasSize()
            drawCanvas()
        })
    }
}

2. canvas의 drawing 부분을 담당하는 함수들

export function drawCircleMenu(canvas, fillStyle = '#c4c4c4') {
    const ctx = canvas.getContext('2d')
    ctx.save()
    ctx.beginPath()
    ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2 - 2, 0, Math.PI * 2, true) // Outer circle
    ctx.fillStyle = fillStyle
    ctx.fill()
    ctx.stroke()
    ctx.restore()
}

... // 필요한 draw함수들을 선언 후 가져다 쓴다

이렇게 두 부분으로 나눈 이유는 drawing 함수들 자체는 Class 내부에 종속될 이유가 크게 없기 때문에, (넣으면 context를 사용하지 않을 수 있지만, class를 가볍게 하기 위해 parameter로 돌렸다) 따로 선언하는 방식을 택했다. 이 함수들이 많아지면 utils.js가 아닌 drawing.js로 분리할 예정.


결론

이제 막 만들어낸 구조이기 때문에, 사용하다 불편하면 고칠 수도 있지만, 이정도 구조만 있어도, 간단한 canvas는 모두 넣을 수 있을테니 필요하신 분들에게 도움이 되면 좋겠다.

 

다음은 바쁘신 분들을 위한 코드 전문이다

 

1. .vue

<template>
  <div style="width: 100%; height: 100%; padding: 20px 60px">
    <canvas id="circle-menu" style="width: 100%; height: 100%"></canvas>
  </div>
</template>

<script>
import { defineComponent, onMounted } from '@vue/composition-api'
import VueCanvas, { drawCircleMenu } from '~/components/canvas/utils'
export default defineComponent({
  setup() {
    const mounted = () => {
      const canvas = document.getElementById('circle-menu')

      const vueCanvas = new VueCanvas(canvas)
      drawCircleMenu(vueCanvas.canvas)

      vueCanvas.makeCanvasResponsive(() => {
        drawCircleMenu(vueCanvas.canvas)
      })
    }
    onMounted(mounted)

    return {}
  },
})
</script>


<style>
</style>

2. utils.js

export default class VueCanvas {
    constructor(canvas) {
        this.canvas = canvas
        this.ctx = canvas.getContext('2d')
        this.responseCanvasSize()
    }

    responseCanvasSize() {
        const rect = this.canvas.getBoundingClientRect()
        this.canvas.width = rect.width
        this.canvas.height = rect.height
    }

    makeCanvasResponsive(drawCanvas) {
        window.addEventListener('resize', () => {
            this.responseCanvasSize()
            drawCanvas()
        })
    }
}

export function drawCircle(canvas, x, y, radius, startAngle, finishAngle, fillStyle = '#c4c4c4') {
    const ctx = canvas.getContext('2d')
    ctx.save()
    ctx.beginPath()
    ctx.arc(x, y, radius, startAngle, finishAngle, true) // Outer circle
    ctx.fillStyle = fillStyle
    ctx.fill()
    ctx.stroke()
    ctx.restore()
}

export function drawCircleMenu(canvas, fillStyle = '#c4c4c4') {
    const ctx = canvas.getContext('2d')
    ctx.save()
    ctx.beginPath()
    ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2 - 2, 0, Math.PI * 2, true) // Outer circle
    ctx.fillStyle = fillStyle
    ctx.fill()
    ctx.stroke()
    ctx.restore()
}