티스토리 뷰
단계별 진행
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 |
---|