티스토리 뷰

단계별 진행

Canvas 드로잉의 Best Practice를 알아가기 위해

한번에 모든것을 구현하지 않고 단계별로 나눠서 진행을 하고 있습니다.

 

<Base Canvas> 컴포넌트

기본이 되는 캔버스 컴포넌트를 제작해주었습니다.

최종적으로 원하는 방향은 <기본 캔버스> ~ <캔버스 모듈> ~ <캔버스 페이지> 과 같이

단방향 사용성을 목표로 두고 작업을 진행하였습니다.

 

간단하게 canvas 드로잉을 위한 속성값과 이벤트들을 받았습니다.

단계적으로 진행되어 추후 더 받아야할 타입들은 추가될 예정입니다.

 

사이즈

캔버스는 width(너비) 와 height(높이) 속성값을 받는데,

해당 값은 숫자가 입력되면 픽셀(px)로 적용이 되어 캔버스의 크기를 설정하게 됩니다.

( width와 height는 ref 를 통해서 설정을 할 수 있도록 바꾸었습니다. )

 

이벤트

마우스(onMouse...)와 터치(onTouch...) 이벤트들을 다 받았지만, 두 상황에서 사용이 가능하고

받는 이벤트의  클릭 위치 키( 마우스클릭과 터치클릭의 위치를 받을 때 event.offsetX, event.offsetY 와 같은 )가

같은 포인터(onPointer) 이벤트도 받아주었습니다. 작업을 진행할 때에는 mouse, touch 이벤트 대신

포인터 이벤트를 사용할 계획입니다.

 

스타일

캔버스의 기본적인 스타일은 가로, 세로 100%, 터치 액션을 제거해주었습니다.

속성값의 width 와 height로 설정된 사이즈는 canvas의 컨텍스트(context)를 보여주는 사이즈를 설정해주어서

캔버스를 부모 컨테이너의 사이즈에 맞출 수 있는 스타일(Css) 설정이 필요했습니다.

그리고 touch-action:none 을 주어 터치 액션에 대한 제한을 주었습니다.

해당 요소는 이후 캔버스의 확대 축소 동작을 작업할 때에 컨트롤이 필요한지 확인이 필요해보였습니다.

 

지원 여부

캔버스의 지원여부를 canvas 태그의 자식으로 넣게되면

지원하지 않았을때 보여줄 컨텐츠를 설정해줄 수 있습니다.

import React, {
  MouseEventHandler,
  TouchEventHandler,
  PointerEventHandler,
} from 'react';
import styled from 'styled-components';

type BaseCanvasProps = {
  width?: number;
  height?: number;
  onMouseDown?: MouseEventHandler;
  onMouseMove?: MouseEventHandler;
  onMouseUp?: MouseEventHandler;
  onTouchStart?: TouchEventHandler;
  onTouchMove?: TouchEventHandler;
  onTouchEnd?: TouchEventHandler;
  onPointerDown?: PointerEventHandler;
  onPointerMove?: PointerEventHandler;
  onPointerUp?: PointerEventHandler;
};

const BaseCanvas = React.forwardRef<HTMLCanvasElement, BaseCanvasProps>(
  (props, ref) => {
    return (
      <Canvas ref={ref} {...props}>
        해당 브라우저가 캔버스를 지원하지 않습니다.
      </Canvas>
    );
  },
);

const Canvas = styled.canvas`
  width: 100%;
  height: 100%;
  touch-action: none;
`;

export { BaseCanvas };

 

<캔버스 모듈>

<캔버스 모듈><캔버스 페이지> 간 경계를 단계를 거쳐 최적의 코드베이스를 만들고 적용하는 방향으로

<캔버스 모듈> <캔버스 페이지>에 바로 작업을 진행하였습니다.

 

다음과 같이 기본적인 구조를 잡았습니다.

1. isDown 상태 선언 및 초기 상태 false, pointer 이벤트 down, move, up 생성 및 핸들러 부여, canvas dom을 컨트롤 하기 위한 ref 선언

2. pointerDown 이벤트가 발생하면 isDown 상태 => true

3. pointerMove 이벤트의 내부 코드는 isDown 상태가 true 일때만 작동

4. pointerUp 이벤트가 발생하면 isDown 상태 => false 로 pointerMove 이벤트 내부 코드 작동 제한

import { useEffect, useState, useRef, PointerEvent } from 'react';

// ...import something

const CanvasBasicDrawPage: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [isDown, setIsDown] = useState<boolean>(false);
    
  const handlePointerDown = () => {
    setIsDown(true);
  };

  const handlePointerMove = () => {
    if (!isDown) return;
    // move doing
  };

  const handlePointerUp = () => {
    setIsDown(false);
  };

  return (
    <div>
      <BaseCanvas
        ref={canvasRef}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      />
    </div>
  );
};

export { CanvasBasicDrawPage };

 

컨텍스트, 캔버스 정보

캔버스에 무언가를 그리기 위해서는 컨텍스트 api 를 사용해야합니다.

context와 canvas의 정보를 담기위한 ref를 생성해줍니다.

const contextRef = useRef<CanvasRenderingContext2D | null>(null);

const canvasInformRef = useRef({
  width: 0,
  height: 0,
  pixelRatio: window.devicePixelRatio > 1 ? 2 : 1,
});

 

 

캔버스 사이즈 설정

 

캔버스 사이즈의 설정에는 두가지 동작이 필요합니다.

- canvas의 width, height 설정

- context의 스케일 설정

 

devicePixelRatio

그 과정에서 devicePixelRatio라는 것을 사용합니다.

devicePixelRatio는 CSS 픽셀을 그릴 때 사용해야하는 장치 픽셀의 수를 나타냅니다.

// get Device Pixel Ratio
const devicePixelRatio = window.devicePixelRatio;

// 1또는 2 값을 받아옵니다.
const devicePixelRatioForTwoPoint = window.devicePixelRatio > 1 ? 2 : 1;

해당 값을 통해서 HiDPI/Retina 디스플레이와 같이 보다 선명한 이미지를 표현하는 화면( 객체를 그릴 때 더 많은 픽셀을 사용 ), 표준 디스플레이의 렌더링 차이에 대응할 수 있습니다.

 

캔버스 세팅 동작과정

1. useEffect를 통해서 init함수를 호출합니다.

2. init 함수는 캔버스의 사이즈, 컨텍스트를 설정하는 함수를 호출합니다.

3. canvasSizeSetting

- init 함수에서 window의 너비 높이 사이즈를 매개변수로 보내줍니다.

- canvasRef 를 통해서 캔버스 dom 정보가 있다면 다음을 실행합니다.

- 각 width, height 값에 pixelRatio 정보를 곱해줍니다.

- 해당 값을 canvas dom의 width height 에 설정해줍니다.

- 해당 정보를 canvasInformRef 에 저장해둡니다.

4. canvasContextSetting

- init 함수에서 canvasRef의 context를 생성해 인자로 넘겨줍니다.

- 매개 변수로 받은 context의 scale, lineCap, strokeStyle, lineWidth 정보들을 설정해줍니다.

- 설정 된 context 값을 contextRef 에 반환합니다.

// component onMount start setting
useEffect(() => {
    init();
}, []);

// canvas size setting function
const canvasSizeSetting = (width: number, height: number) => {
    if (!canvasRef.current) return;

    const resultWidth = width * canvasInformRef.current.pixelRatio;
    const resultHeight = height * canvasInformRef.current.pixelRatio;
    canvasRef.current.width = resultWidth;
    canvasRef.current.height = resultHeight;
    canvasInformRef.current.width = resultWidth;
    canvasInformRef.current.height = resultHeight;
};

// canvas context setting function
const canvasContextSetting = (context: CanvasRenderingContext2D) => {
    if (!context) return;
    context.scale(
      canvasInformRef.current.pixelRatio,
      canvasInformRef.current.pixelRatio,
    );
    context.lineCap = 'round';
    context.strokeStyle = 'black';
    context.lineWidth = 3;
    contextRef.current = context;
};

// canvas initializer setting function
const init = () => {
    if (!canvasRef.current) return;
    const { innerWidth, innerHeight } = window;
    canvasSizeSetting(innerWidth, innerHeight);
    const getContext = canvasRef.current.getContext('2d');
    if (!getContext) return;
    canvasContextSetting(getContext);
};

 

캔버스 그리기

캔버스를 그리는 동작은 기본적인 구조를 잡았던, 

handlePointerDown, handlePointerMove, handlePointerUp 이벤트 핸들러를 통해서 구현합니다.

 

동작 과정

1. 마우스 클릭 또는 터치가 시작되었을 때 handlePointerDown 이벤트 핸들러가 동작합니다.

- isDown 상태를 true로 변경합니다

- nativeEvent를 통해서 클릭 된 X, Y 좌표를 불러옵니다.

- context의 beginPath 메서드로 패스의 시작을 알립니다.

- context의 moveTo 메서드를 통해서 클릭한 지점의 좌표로 캔버스의 시작 좌표를 움직입니다.

2. isDown 이 true 인 상태에서 handlePointerMove 이벤트 핸들러가 동작했을 때

- nativeEvent를 통해서 클릭 된 X, Y 좌표를 불러옵니다.

- 이벤트가 동작할 때마다 pointerDown 에서 시작된 지점부터 시작해서

context의 lineTo 메서드를 통해서 좌표를 움직여줍니다.

- 이벤트가 동작할 때마다 context의 stroke 메서드를 통해서 라인을 그려줍니다.

3. 마우스 또는 터치 이벤트가 종료가 되었을 때 handlePointerUp 이벤트가 동작합니다.

- isDown 상태를 false로 바꿔서 handlePointerMove 의 동작을 막습니다.

const handlePointerDown = ({ nativeEvent }: PointerEvent) => {
    setIsDown(true);
    if (!contextRef.current) return;

    const { offsetX, offsetY } = nativeEvent;

    contextRef.current.beginPath();
    contextRef.current.moveTo(offsetX, offsetY);
  };

  const handlePointerMove = ({ nativeEvent }: PointerEvent) => {
    if (!isDown || !contextRef.current) return;

    const { offsetX, offsetY } = nativeEvent;
    contextRef.current.lineTo(offsetX, offsetY);
    contextRef.current.stroke();
  };

  const handlePointerUp = () => {
    setIsDown(false);
    if (!contextRef.current) return;
    contextRef.current.closePath();
  };

 

전체 코드는 다음과 같습니다.

 

const CanvasBasicDrawPage: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const contextRef = useRef<CanvasRenderingContext2D | null>(null);

  const canvasInformRef = useRef({
    width: 0,
    height: 0,
    pixelRatio: window.devicePixelRatio > 1 ? 2 : 1,
  });

  const [isDown, setIsDown] = useState<boolean>(false);

  useEffect(() => {
    init();
  }, []);

  const canvasSizeSetting = (width: number, height: number) => {
    if (!canvasRef.current) return;

    const resultWidth = width * canvasInformRef.current.pixelRatio;
    const resultHeight = height * canvasInformRef.current.pixelRatio;
    canvasRef.current.width = resultWidth;
    canvasRef.current.height = resultHeight;
    canvasInformRef.current.width = resultWidth;
    canvasInformRef.current.height = resultHeight;
  };

  const canvasContextSetting = (context: CanvasRenderingContext2D) => {
    if (!context) return;
    context.scale(
      canvasInformRef.current.pixelRatio,
      canvasInformRef.current.pixelRatio,
    );
    context.lineCap = 'round';
    context.strokeStyle = 'black';
    context.lineWidth = 3;
    contextRef.current = context;
  };

  const init = () => {
    if (!canvasRef.current) return;
    const { innerWidth, innerHeight } = window;
    canvasSizeSetting(innerWidth, innerHeight);
    const getContext = canvasRef.current.getContext('2d');
    if (!getContext) return;
    canvasContextSetting(getContext);
  };

  const handlePointerDown = ({ nativeEvent }: PointerEvent) => {
    setIsDown(true);
    if (!contextRef.current) return;

    const { offsetX, offsetY } = nativeEvent;

    contextRef.current.beginPath();
    contextRef.current.moveTo(offsetX, offsetY);
  };

  const handlePointerMove = ({ nativeEvent }: PointerEvent) => {
    if (!isDown || !contextRef.current) return;

    const { offsetX, offsetY } = nativeEvent;
    contextRef.current.lineTo(offsetX, offsetY);
    contextRef.current.stroke();
  };

  const handlePointerUp = () => {
    setIsDown(false);
    if (!contextRef.current) return;
    contextRef.current.closePath();
  };

  return (
    <div>
      <BaseCanvas
        ref={canvasRef}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      />
    </div>
  );
};

 

위와 같이 하면 캔버스에 클릭을 통해서 드로잉을 할 수 있습니다.

 

이벤트 발생의 속도

하지만 이벤트의 발생 속도가 드로잉 속도를 따라가지 못한다면( 마우스 또는 터치를 빠르게해서 드로잉 한다면 )

다음과 같이 굴곡진 라인이 아닌 각진 라인이 나오게 될겁니다.

빠르게 드로잉 해서 각진 드로잉이 나온 사진

위와 같은 현상을 위해 그릴때 커브를 이용해서 그리면 해결할 수 있게되는데,

다음 글에서 해당 내용을 다뤄보려합니다.

'프로젝트 > Canvas App' 카테고리의 다른 글

[Canvas App] 프로젝트 세팅하기_Alias  (0) 2022.07.11
댓글
최근에 올라온 글