개발 기술/개발 이야기

[JS/CSS] corner smoothing을 구현하는 법(feat. 부드러운 둥근 모서리)

by GicoMomg 2024. 6. 18.

1. 들어가기 전에…

  • 우리는 iOS 디바이스에서 표현되는 요소가 부드럽다는 느낌을 받는다. 특히 둥근 모서리를 가진 요소는 더욱 부드럽게 보인다.
  • 물론 iOS의 소프트웨어나 디스플레이가 우수한 이유도 있지만, 모서리를 부드럽게 표현하는 기능이 적용되어 있기 때문이다…!


  • Swift의 경우, IOS 구현시 코너를 부드럽게 하는 옵션(.continuous)을 제공한다.
  • 그래서 RoundedRectangle에 style을 “.continuous”로 지정하면 부드러운 모서리가 적용된다.
RoundedRectangle(cornerRadius: 100, style: .continuous)
.fill(-pink)
.frame(width: 200, height: 150)


  • 물론 Swift를 쓰지 않아도 Figma에서 부드러운 모서리를 테스트할 수 있다. 아래는 Figma 예시이다.
  • 우선 Figma에서 1개의 요소를 생성하고 borderRadius를 준다.


  • 그리고 borderRadius의 상세 설정으로, 모서리의 부드러운 정도(corner smoothing)를 조절할 수 있다.


그렇다면, 외곽선에 smooth(부드러운 효과)가 적용하기 전 후 차이가 존재할까?

  • Figma에서 두 개의 박스 요소를 생성했다. 이 중 우측 박스(민트색)에만 smooth를 60% 적용했다.
  • 두 개를 나란히 두었을 때 그 차이를 바로 알아채기 어렵다.


  • 하지만 두 요소를 겹쳐보면, 모서리의 차이가 있는 걸 볼 수 있다.


이처럼 작은 부분이지만, 부드러운 둥근 모서리는 사용자에게 친숙하고 자연스러운 효과를 준다!
그렇다면, 해당 UI는 웹에서 구현할 수 없을까?

  • Swift처럼 내장 기능은 아니지만, 구현할 수 있는 방법이 있다!
  • 이번 시간에는 "부드러운 둥근 모서리를 구현하는 법"에 대해 알아보겠다. 다만, 이 방법은 브라우저 호환성 및 성능상의 단점을 체크해야 한다.
  • 오늘 코드는 border-smooth-corner 레포 코드(smooth-corners 재구성)를 기반으로 설명한다.



2. 부드러운 외곽선을 구현하는 방법(paintAPI)

1) 어떻게 구현할 수 있을까?

  • borderRadius에 부드러운 느낌을 주기 위해서 Paint API로 커스텀 페인트를 생성해야 한다.
  • 내가 참고했던 라이브러리(smooth-corners)는 요소의 외곽선을 캔버스로 그리되, 외곽선의 점들을 초타원형(superellipse) 방식으로 구현했다.
  • 여기서 초타원형(superellipse)이란, 타원과 사각형 등 다양한 형태의 곡선을 생성하는 수학적인 개념이다. 방정식은 아래와 같다:


  • 결론적으로, n의 수에 따라 둥근 곡선을 만들 수 있는 방정식이다.
  • smooth-corners 라이브러리는 배경색을 가진 부드러운 모서리 사각형을 구현할 수 있다.
  • 하지만 배경색이 없는 경우 일부 외곽선이 짤리는 현상이 있다. 또한, 캔버스로 외곽선을 그리다 보니
    선이나 사각형 등을 그릴 때 픽셀 경계에 정확히 맞지 않아 계단 현상(aliasing)이 발생했다.
  • 그래서 smooth-corners를 기반으로 (1) 배경색이 없는 부드러운 외곽선이 가능하면서 (2) 캔버스 계단 현상을 보완했다.
  • 코드 구현을 보기 전에 Paint API가 무엇인지 간단히 알고 넘어가자.



2) Paint API는 무엇일까?

  • Paint API는 브라우저에서 CSS로 정의할 수 없는 복잡한 그래픽을 캔버스에 직접 그릴 수 있는 API다.
  • 이 API를 사용하면 CSS 속성으로 표현할 수 없는 사용자 정의 배경, 테두리, 강조 표시 등을 만들 수 있다.
  • 다만 2024.06 기준 실험 기능이기에 호환성에 한계가 있다.

CSS Paint API는 어떻게 사용해야할까?
CSS Paint API를 사용해, 배경색 박스를 만드는 커스텀 그래픽을 구현해보자!
배경색 박스를 만드는 커스텀 그래픽의 이름은 ‘backgroundPaint’라고 명명했다.

(1) CSS 속성 정의

  • 우선 paint()를 사용해, 구현 예정인 커스텀 그래픽(backgroundPaint)을 적용한다.
  • 그리고 배경색을 지정하기 위해 사용자 정의 속성(—-paint-color)에 값을 지정한다.
  • 우리는 빨간색 박스를 만들고 싶기에, —-paint-color를 ‘red’로 설정했다.
/* index.css */

.custom-element {
  --paint-color: red;
  width: 100px;
  height: 100px;
  background-image: paint(backgroundPaint); /* 커스텀 그래픽 등록 */
}

(2) Paint Worklet 작성

  • 아래 코드는 사용자가 정의한 CSS 속성(--paint-color)에 따라 배경색을 설정하는 페인트 클래스( Paint Worklet)를 정의하고 등록했다.
  • 이 클래스를 사용하면 웹 페이지의 특정 요소에 동적으로 배경색을 적용할 수 있다.
// index.js

class BackgroundPaint {
  // (a)
  static get inputProperties() { return ['--paint-color']; }


  // (b)
  paint(ctx, geom, properties) {
    const color = properties.get('--paint-color').toString();
    ctx.fillStyle = color;
    ctx.fillRect(0, 0, geom.width, geom.height);
  }
}

// (c)
if (typeof registerPaint !== "undefined") {
  registerPaint('backgroundPaint', BackgroundPaint);
}
코드 번호 설명
(a) BackgroundPaint 클래스에서 사용할 사용자 정의 속성 목록을 반환한다.
(b) paint(ctx, geom, properties)는 그래픽을 그리는 로직을 정의한다.
properties.get('--paint-color')--paint-color 변수값을 가져와 배경색으로 사용한다.
(c) registerPaint 함수가 정의되어 있는 경우에만 backgroundPaint라는 이름으로 BackgroundPaint 클래스를 등록한다.

(3) 모듈 등록하기

  • CSS.paintWorklet.addModule을 사용해 앞서 선언한 Paint Worklet을 등록한다.
  • 이를 통해, 페이지 로드 시 Paint Worklet이 사이트에 등록된다.
<!-- index.html -->

<script id="register-worklet">
  if (CSS.paintWorklet) { 
    CSS.paintWorklet.addModule('paint_worklet을 구현한 js 파일');
  }
</script>

(4) 적용하기

  • 앞선 등록 과정을 거치면, .custom-element 요소의 배경색이 빨간색으로 변한 걸 볼 수 있다.
  • 이처럼 CSS Paint API를 사용하면 웹 페이지에서 다양한 그래픽을 요소에 적용할 수 있다.
<!-- index.html -->

<div class="custom-element">hi!</div>

(5) 결과보기

See the Pen css paint demo by KumJungMin (@kumjungmin) on CodePen.



3) PaintAPI로 smooth corner 구현하기

Paint API에 대해서 알았으니, 이 API를 사용해 Smooth Corners Painter 클래스를 구성해보자!

(1) 전반적인 코드 보기

  • 클래스는 다음과 같은 메서드와 속성을 포함하고 있다:
// index.js 

class SmoothCornersPainter {
  static get inputProperties() {
    return [ "--smooth-corners", "--bg-color", "--border-color", "--border-width" ];
  }

  propertyValue(properties, key) {
    // CSS 속성 값 가져오기 로직
  }

  getSuperellipse(a, b, n = 4, strokeWidth) {
    // 슈퍼엘립스 곡선 생성 로직
  }

  paint(ctx, geom, properties) {
    // 그래픽 렌더링 로직
  }
}

if (typeof registerPaint !== "undefined") {
  registerPaint("smooth-corners", SmoothCornersPainter);
}

(2) inputProperties, 커스텀 속성을 관리한다.

static get inputProperties() {
  return [ "--smooth-corners", "--bg-color", "--border-color", "--border-width" ];
}
  • inputProperties 메서드는 특정 CSS custom properties (커스텀 속성)의 리스트를 반환한다.
  • --smooth-corners는 모서리를 얼만큼 둥글게 할 건지 결정한다.
  • --bg-color--border-color는 배경색과 테두리 색상을 설정하고,
  • --border-width는 테두리 두께를 설정할 때 쓰인다.

(3) propertyValue, 커스텀 속성값에 접근한다.

  • CSS 속성 맵(properties)에서 특정 CSS 속성(key)의 값을 가져오는 함수이다.
  • 이 함수를 이용하면 커스텀 속성값에 접근할 수 있다.
propertyValue(properties, key: InputProperty) {
  const defaultConfig = {
    "--smooth-corners": 4,
    "--bg-color": "transparent",
    "--border-color": "black",
    "--border-width": 1,
  };
  return properties.get(key)[0] || defaultConfig[key];
}

(3) getSuperellipse, 초타원형 점을 생성한다.

  • widthheight를 기반으로 superellipse(초타원형)의 점들을 생성하는 함수이다.
  • 반환된 points 배열은 초타원형 그래픽을 그릴 때 쓰인다.
getSuperellipse(a, b, n = 4, strokeWidth) {
  let m = n;

   // m이 100보다 크면 100으로 제한
   // m이 0.00000000001보다 작으면 0.00000000001으로 제한
  if (m > 100) m = 100;
  else if (m < 0.00000000001) m = 0.00000000001; 

  const m2 = 2 / m;

    // 원을 360도로 나누어 점을 생성
  const steps = 360; 

  // 각도 당 간격
  const step = (2 * Math.PI) / steps;

  // t에 대한 점을 계산하는 함수
  const points = (t: number) => {
    const cosT = Math.cos(t); // t에 대한 코사인 값
    const sinT = Math.sin(t); // t에 대한 사인 값

    // x, y 좌표 계산
    const x = Math.abs(cosT) ** m2 * a * Math.sign(cosT);
    const y = Math.abs(sinT) ** m2 * b * Math.sign(sinT);

    // 테두리 클리핑을 피하기 위해 점 조정
    const adjustX = x > 0 ? x - strokeWidth / 2 : x + strokeWidth / 2;
    const adjustY = y > 0 ? y - strokeWidth/ 2 : y + strokeWidth / 2;

    return { x: adjustX, y: adjustY };
  };

  // 0부터 2π까지의 각도에 대해 points 함수를 호출하여 점들을 배열로 반환
  return Array.from({ length: steps }, (_, i) => points(i * step));
}

(4) paint, 점 위치에 따라 그래픽을 그린다.

  • 앞선 getSuperellipse 함수의 리턴 값(점)을 기준으로 그래픽을 그린다.
paint(ctx, geom, properties) {
  // 코너 부드러운 정도
  const n = Number(this.propertyValue(properties, "--smooth-corners"));

  // 선 굵기
  const strokeWidth = Number(this.propertyValue(properties, "--border-width"));

  // 배경색
  const bgColor = this.propertyValue(properties, "--bg-color");

  // 선 색상
  const borderColor = this.propertyValue(properties, "--border-color");

  const width = geom.width / 2;   // 요소의 너비의 절반
  const height = geom.height / 2; // 요소의 높이의 절반
  const points = this.getSuperellipse(width, height, n, strokeWidth); // 점들

  // Canvas 변환 매트릭스를 설정하여 중심을 요소의 가운데로 이동
  ctx.setTransform(1, 0, 0, 1, width, height); 

  // 그리기 시작
  ctx.beginPath();             
  ctx.fillStyle = bgColor;   
  ctx.strokeStyle = borderColor; 
  ctx.lineWidth = strokeWidth; 

  // 각 점을 따라 이동하면서 선을 그림
  for (const [i, { x, y }] of points.entries()) {
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);  
  }

  // 그리기 끝
  ctx.closePath();
  ctx.fill();  
  ctx.stroke();  
}

(5) 커스텀 그래픽을 등록한다.

  • "smooth-corners"라는 이름으로, 커스텀 페인트를 등록한다.
if (typeof registerPaint !== "undefined") {
  registerPaint("smooth-corners", SmoothCornersPainter);
}

(6) 커스텀 그래픽을 모듈로 등록한다.

  • 앞서 선언한 커스텀 그래픽을 모듈로 등록하면, 사용이 가능하다!
<!-- index.html -->

<script>
  if (CSS.paintWorklet) { 
      CSS.paintWorklet.addModule('https://codepen.io/robinrendle/pen/YzbVXjv.js');
  }
</script>

(7) 결과보기

See the Pen border smooth example by KumJungMin (@kumjungmin) on CodePen.




3. 마치며…

이번 시간에는 CSS Houdini의 Paint API를 사용해 부드러운 외곽선을 만드는 방법을 알아보았다.

CSS Paint API는 다양한 그래픽을 생성할 수 하지만, 성능 문제와 지원 범위에 대해 신중히 평가해야 한다. 특히 Paint API는 캔버스를 기반으로 그래픽을 그리기 때문에 단순 borderRadius CSS 속성에 비해 리소스가 더 필요하다. 그래서 이 기능을 바로 적용하기 보다는 추후 최신 기술 동향을 주의 깊게 관찰하고, 프로젝트의 요구사항에 맞춰 적절한 기술 선택과 최적화를 진행하는 것이 중요하다.

오늘 소개한 코드를 다시 보고 싶다면, 이 링크를 확인하면 된다.


반응형

댓글