현재 제가 진행하고 있는 Solo Play 프로젝트에서 디자인을 담당한 팀원분께서 육각형 UI를 부탁하셨습니다. 그래서 흔쾌히 수락했고, CustomPainter를 이용하여 육각형 모양의 위젯을 제작하였습니다. 오늘은 그 과정을 소개하며 제가 경험했던 고민과 해결책을 함께 나눠보려합니다.
메인 UI에서는 여러 지역 중 사용자가 원하는 지역을 선택하여 세부 지역을 선택하는 UI입니다. 여기서 지역 UI는 단순하게 지도를 보여주는 것보다는 특색이 있게 각 구역을 육각형으로 표현하여 HexagonGrid 형태로 표현하기로 합의를 보았습니다.
그 메인 UI는 제가 담당하기로 했죠.(플래그 ON)
회의를 끝마치고 디자이너에게 와이어 프레임을 전달받자마자 작업을 위해서 팀 Figma로 접속해서 봤더니....
솔직히 그냥 봤을때는 단순하게 "음... 육각형 그리는 건 그래도 재밌겠네"생각했죠. 근데, 위 캡쳐로 본 와이어프레임....뭐가 좀 이상하지 않나요?
얼핏보면 평범한 육각형 같은데... 뭔가 이질적인....
![]()
이거 왜 둥글죠? 디자이너님의 UI 제작 실력에 감탄한 나머지 찬사를 보냈습니다.
ㅇㅋ 만들어보자.
Flutter에서는 CustomPainter를 이용하여 내가 원하는 도형을 만들 수 있습니다. 그래서 CustomPainter를 이용하여 육각형 위젯을 제작해보겠습니다.
우선, CustomPainter를 자세히 알아보고 싶다면 공식문서를 확인해보세요.
https://api.flutter.dev/flutter/rendering/CustomPainter-class.html저는 Path 클래스로 경로를 만들어서 육각형을 그려볼거에요. moveTo 메소드와 lineTo 메소드를 사용하면 점의 위치를 변경할 수 있고, 목표 좌표까지의 선을 그릴 수 있습니다.
대충, moveTo로 시작점으로 이동하고, lineTo를 이용해서 선분을 그리면 될 것 같네요.
육각형을 그리려면 꼭지점의 위치를 계산해야 됩니다. 정육각형은 특히나, 꼭지점과 원점과의 거리가 모두 동일해요. 그래서 이렇게 볼 수 있습니다.
육각형은 원 위에 그려지고, 각 꼭지점은 각도에 따른 삼각함수를 통해 구할 수 있습니다.
는 0, 60, 120, 180, 240, 300도까지 적용이 됩니다.이제 이론적으로 구상은 끝났으니 코드를 작성해보죠.
import 'dart:math';
import 'package:flutter/material.dart';
class HexagonTile extends StatelessWidget {
final Color? color;
final double width;
const HexagonTile({super.key, this.color, this.width = 143});
Widget build(BuildContext context) {
return CustomPaint(
size: Size(width, width),
painter: HexagonPainter(width: width),
);
}
}
기본적으로 CustomPainter로 무언가를 보여주려면 CustomPaint 위젯을 이용해야 합니다. 여기에는 size와 painter를 지정할 수 있는데, size는 CustomPaint가 제공할 canvas의 크기 쉽게 말하자면 도화지의 사이즈를 지정하는 것이구요. painter는 직접 제작한 CustomPainter 클래스를 전달하면 화면에 보여지게 됩니다.
class HexagonPainter extends CustomPainter {
final double width;
const HexagonPainter({
required this.width,
});
void paint(Canvas canvas, Size size) {
// canvas의 크기의 절반이 radius
final radius = width / 2;
const angle = (pi * 2) / 6;
Paint paint = Paint()
..color = Colors.grey
..strokeWidth = 4
..style = PaintingStyle.fill;
final path = Path();
final firstOffset = Offset(radius * cos(0.0), radius * sin(0.0));
// 시작점으로 이동
path.moveTo(firstOffset.dx, firstOffset.dy);
for (int i = 1; i <= 6; i++) {
// 첫번째 각도와 두번째 각도 계산
final currAngle = angle * i;
final currX = radius * cos(currAngle);
final currY = radius * sin(currAngle);
// Hexagon line 끝점으로 라인 그리기
path.lineTo(currX, currY);
}
canvas.drawPath(path, paint);
}
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
결과를 볼까요?
음, 뭔가 중앙이 아니라 살짝 삐져나갔네요. 사실, 이거는 중앙점이 canvas가 시작되는 좌표인 (0, 0)으로 지정되었기 때문이에요. 저희의 육각형은 canvas의 중심이 중점이어야 되지만, 위 코드상으로는 (0, 0)을 기준으로 좌표를 계산이 됩니다.
지금은 이런느낌인거죠. 그래서 좌표 이동을 좀 해줘야 합니다. canvas의 크기는 width로 지정되므로, width / 2가 정확한 중심점의 좌표죠. 이를 이용해서 코드를 수정해줍시다.
class HexagonPainter extends CustomPainter {
final double width;
const HexagonPainter({
required this.width,
});
void paint(Canvas canvas, Size size) {
final radius = width / 2;
final center = Offset(width / 2, width / 2);
const angle = (pi * 2) / 6;
Paint paint = Paint()
..color = Colors.grey
..strokeWidth = 4
..style = PaintingStyle.fill;
final path = Path();
final firstOffset =
Offset(radius * cos(0.0) + center.dx, radius * sin(0.0) + center.dy);
// 시작점으로 이동
path.moveTo(firstOffset.dx, firstOffset.dy);
for (int i = 1; i <= 6; i++) {
// 첫번째 각도와 두번째 각도 계산
final currAngle = angle * i;
final currX = radius * cos(currAngle) + center.dx;
final currY = radius * sin(currAngle) + center.dy;
// Hexagon line 끝점으로 라인 그리기
path.lineTo(currX, currY);
}
canvas.drawPath(path, paint);
}
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
자, 드디어 육각형이 제 위치로 이동했네요.
하 근데 우리의 육각형은 저렇게 뾰족하지 않고, 둥근 모서리를 갖고있죠... borderRadius를 넣어봅시다. Radius는 결국 모서리가 호의 형태를 띕니다.
그래서, 내접하는 원을 만들어서 해당 원의 둘레를 도는 형태를 생각해봤습니다.
모서리 부분에서 내접원을 이용해서 호를 그린다면, 뭔가 나오지 않을까요? 이렇게 아래처럼말이죠.
자, 이런 모양에서 육각형을 그리려면 각도마다 내접하는 원의 두 좌표가 이루는 각이 필요합니다.
이 그림에서 보면 각도가 0인 꼭지점에서는 꼭지점의 각도에서 - 30도부터 30도까지 호를 그리게 됩니다. 마찬가지로 내접원의 중심점의 좌표를 기준으로 하니, 내접원의 중심점의 좌표를 구해야 합니다. 이건 좀 간단한데요(? 슬슬 미쳐감).
이렇게, 꼭지점을 기준으로 내접원과의 거리를 반지름으로 하는 원의 위이므로 각도만 알면 마찬가지로 삼각함수로 구할 수 있는 것이죠. 그리고 현재 각도보다 180도 큰 위치에 중심점이 위치하므로 반지름만 알면 됩니다. 반지름은 마찬가지로 삼각비로 구하면 돼요. 모양은 뭣같아도 양해부탁드립니다. ㅎㅎ
꼭지점은 120도이므로 구하고자 하는 반지름 를 기준으로 30도 60도로 이루어진 직각삼각형이 등장합니다. 그래서 아래와 같은 관계식을 알 수 있습니다.
즉, 주어지는 borderRadius에 따라서 저 관계식을 통하여 꼭지점과 내접원이 이루는 원의 반지름을 구할 수 있습니다. 이제 내접원의 꼭지점 좌표는 아래와 같죠.
자, 이제 이 좌표를 중심으로 하는 원 중에 borderRadius만큼의 반지름을 갖으면서 각 해당 각도마다 호를 그려주면 됩니다.
이 작업은 path의 arcTo 메소드를 이용할겁니다.
Path 클래스는 acrTo 메소드를 통해서 전달된 Rect에서 호를 그릴 수 있게 해줍니다.
Rect는 실질적으로 원이 그려진 사각형이기에 canvas와 비슷한 역할을 하게 됩니다. Rect.fromCenter 생성자를 통해서 중심점과 반지름을 전달하면 원하는 Rect를 선언할 수 있습니다.
우리가 그리고자하는 Rect는 중심점을 로 하고 borderRadius를 반지름으로 갖습니다. 따라서 Rect는 아래와 같습니다.
final oval = Rect.fromCircle(center: Offset(dx, dy), radius: r);
이제 이 Rect에서 호를 그리기 위해서 startAngle과 sweepAngle을 전달해야 합니다. startAngle은 0도를 기준으로 호가 시작되는 각도를 지정하는 인자이고, sweepAngle은 실질적인 호의 각도입니다. 저희가 만들 호는 꼭지점의 각도를 기준으로 - 30도부터 60도의 각도를 갖는 호를 그려야 합니다.
path.arcTo(oval, currAngle - pi / 6, pi / 3, false);
자, 마지막 인자인 forceMoveTo는 공식문서에서 어떤 역할을 하는지 설명합니다.
"If the forceMoveTo argument is false, adds a straight line segment and an arc segment.
If the forceMoveTo argument is true, starts a new sub-path consisting of an arc segment."
즉, false로 지정하면 arc를 연결하는 직선을 추가하고, 그렇지 않는 경우에는 선을 연결하지 않는다고 하네요!
그러면 호만 그리면 알아서 둥근 정육각형을?!!?!?!?!?!
class HexagonPainter extends CustomPainter {
final double width;
final double borderRadius;
const HexagonPainter({
required this.width,
required this.borderRadius,
});
void paint(Canvas canvas, Size size) {
final radius = width / 2;
final center = Offset(width / 2, width / 2);
const angle = (pi * 2) / 6;
Paint paint = Paint()
..color = Colors.grey
..style = PaintingStyle.fill;
final path = Path();
final firstOffset =
Offset(radius * cos(0.0) + center.dx, radius * sin(0.0) + center.dy);
// 시작점으로 이동
path.moveTo(firstOffset.dx, firstOffset.dy);
for (int i = 1; i < 7; i++) {
// 첫번째 각도와 두번째 각도 계산
final currAngle = angle * i;
final currX = radius * cos(currAngle) + center.dx;
final currY = radius * sin(currAngle) + center.dy;
// 내접원의 반지름
final r = borderRadius;
// 내접원과 꼭지점과의 거리
final d = 2 * sqrt(3.0) * r / 3;
// 내접원의 x좌표
final dx = d * cos(currAngle + pi) + currX;
// 내접원의 y좌표
final dy = d * sin(currAngle + pi) + currY;
// 내접원을 그릴 Rect
final oval = Rect.fromCircle(center: Offset(dx, dy), radius: r);
path.arcTo(oval, currAngle - pi / 6, pi / 3, false);
}
canvas.drawPath(path, paint);
}
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
자 오늘은 borderRadius를 갖는 육각형을 만들어보았습니다 ㅎㅎ!