서론
이번 시간에는 Flutter 의 매우 강력한 기능중 하나인 Custom Painter 에 대해 배워보도록 하겠습니다. 연습으로 아래 Apple Watch Hermes 에디션의 워치 페이스를 그려보도록 하겠습니다. 아 전 물론 Hermes 에디션이 없습니다. 없으니 그려서라도 갖어보려구요.
Youtube
Custom Paint Init
일단 CustomPainter
를 초기화 해볼게요. 방법은 원하시는 클래스에
CustomPainter
클래스를 익스텐드 하시면 됩니다. override
2개를 필수로 implement 하게 되어있는데 paint
는 실제로 저희가
화면을 그릴때 사용하고 shouldRepaint
는 다시 paint
를
실행해야 하는 조건을 설정할 수 있습니다. 일단 true
를 반환해서
지속적으로 다시 페인트 하도록 할게요.
Watchpainter.dart
class WatchPainter extends CustomPainter{
void paint(Canvas canvas, Size size) {
}
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
일단 아래와 같이 색상들을 먼저 지정 해줄게요
WatchPainter.dart
final Color primaryColor = Color(0xFFE57242);
final Color bgColor = Color(0xFF000000);
final Color accentColor = Color(0xFFFFFFFF);
대충 따온거라 정확한 색상인지는 잘 모르겠어요.
스크린 초기화
일단 스크린에 WatchPainter
클래스를 불러놓고 라이브 업데이트로
화면을 봐보도록 할게요. 배경은 검정색으로 하겠습니다.
Screen.dart
class CustomPaintHermesAppleWatch extends StatefulWidget {
_CustomPaintHermesAppleWatchState createState() => _CustomPaintHermesAppleWatchState();
}
class _CustomPaintHermesAppleWatchState extends State<CustomPaintHermesAppleWatch> {
Widget build(BuildContext context) {
return Scaffold(
body: Container(
alignment: Alignment.center,
color: Color(0xFF000000),
child: CustomPaint(
painter: WatchPainter(),
),
),
);
}
}
숫자 그리기
시계의 숫자를 하나만 가온데에 그려볼게요. 안타깝게도 에르메스 폰트를 제가 소유하고 있지 않기 때문에 갓 애플의 기본 폰트를 사용하겠습니다.
WatchPainter.dart
void paint(Canvas canvas, Size size) {
final xCenter = size.width / 2;
final yCenter = size.height / 2;
renderTimeText(canvas, Offset(xCenter, yCenter), '1');
}
renderTimeText(Canvas canvas, Offset offset, String number) {
final textPainter = TextPainter(
text: TextSpan(
text: number,
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold,
color: primaryColor,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, offset);
}
이렇게 가온데 에르메스 칼라의 숫자를 그렸어요
시간별 숫자 배치하기
숫자를 동그라미로 배치하는건 어렵지 않은데 위 에르메스 시계처럼 숫자를 배치하려고 하니 한번에 할 수 있을만한 공식이 안떠오르더라구요.. 나이먹고 머리가 굳어버린건가.. 어쨋든 그래서 그냥 위치를 수동으로 구해서 리스트에 집어 넣었습니다. 좋은 방법 아시면 알려주세용~
WatchPainter.dart
class WatchPainter extends CustomPainter {
final Color primaryColor = Color(0xFFE57242);
final Color bgColor = Color(0xFF000000);
final Color accentColor = Color(0xFFFFFFFF);
final TextPainter tp;
WatchPainter()
: this.tp = TextPainter(
textDirection: TextDirection.ltr,
);
void paint(Canvas canvas, Size size) {
final xCenter = size.width / 2;
final yCenter = size.height / 2;
final angle = (2 * pi) / 12;
canvas.save();
canvas.translate(xCenter, yCenter);
renderTime(canvas, size);
canvas.restore();
}
renderTime(Canvas canvas, Size size) {
canvas.save();
final xCenter = size.width / 2;
final yCenter = size.height / 2;
final angle = (2 * pi) / 12;
final vertLen = yCenter / cos(angle);
final horLen = xCenter / sin(angle * 2);
final lengthList = [
yCenter,
vertLen,
horLen,
xCenter,
horLen,
vertLen,
yCenter,
];
for (int i = 0; i < 12; i++) {
canvas.save();
final display = i == 0 ? '12' : i.toString();
canvas.translate(0.0, -lengthList[i % 6]);
tp.text = TextSpan(
text: display,
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold,
color: primaryColor,
),
);
// 글자가 rotation 만큼 돌아간걸
// 원상복귀 하는거예요
canvas.rotate(-angle * i);
tp.layout();
// 글자의 높이와 넓이 만큼 dx dy 를 줘야 가온데 정렬이 돼요
tp.paint(
canvas,
Offset(
-(tp.width / 2),
-(tp.height / 2),
),
);
canvas.restore();
canvas.rotate(angle);
}
canvas.restore();
}
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
canvas.save()
는 현재의 캔버스 상태를 저장하고 canvas.restore()
는 저장하기 전의 상태로 원상복귀를 할 수 있어요. 그래서 캔버스의
각도를 돌려가며 숫자를 그리는 방법을 사용 해봤습니다. Screen
위젯도 아래처럼 코드를 변경해서 2 대 3 비율을 맞춰볼게요.
Screen.dart
Widget build(BuildContext context) {
return Scaffold(
body: Container(
alignment: Alignment.center,
color: Color(0xFF000000),
child: Container(
width: MediaQuery.of(context).size.width / 2,
child: AspectRatio(
aspectRatio: 2/3,
child: CustomPaint(
painter: WatchPainter(),
),
),
),
),
);
}
이렇게 결과가 나왔습니다.
뭔가 숫자가 조금 어긋나 보이죠? 저도 그래서 확인을 해보기 위해 컨테이너에 데코레이션을 넣어봤어요. 그런데 어긋나진 않았더라구요. 묘한 착시현상같아요.
시계 바늘 그리기
시계바늘은 의외로 별로 안어려워요. 각 바늘의 길이를 지정하고 각도는 임의로 정해줄게요
renderHands(
Canvas canvas,
Size size,
double xCenter,
double yCenter,
) {
canvas.save();
DateTime now = DateTime.now();
final innerPaint = Paint()
..color = Colors.black
..strokeWidth = 4.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final outerPaint = Paint()
..color = Colors.white
..strokeWidth = 6.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final secPaint = Paint()
..color = primaryColor
..strokeWidth = 1.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.save();
canvas.rotate(2 * pi / 3);
// 분침
canvas.drawLine(Offset(0, 0), Offset(0, xCenter * 0.9), outerPaint);
canvas.drawLine(Offset(0, 0), Offset(0, xCenter * 0.9), innerPaint);
canvas.restore();
canvas.save();
canvas.rotate(2 * pi);
// 시침
canvas.drawLine(Offset(0, 0), Offset(0, xCenter * 0.5), outerPaint);
canvas.drawLine(Offset(0, 0), Offset(0, xCenter * 0.5), innerPaint);
canvas.restore();
canvas.save();
canvas.rotate(pi);
canvas.drawLine(Offset(0,0), Offset(0, xCenter * 0.9), secPaint);
canvas.restore();
canvas.restore();
}
아래처럼 결과가 나왔어요.
섬세작업
에르메스 워치 페이스 사진을 잘 보면 사실 가온데 부분이 조금 더 얇아요. 이걸 한번 구현 해볼게요.
WatchPainter.dart
renderHands(
Canvas canvas,
Size size,
double xCenter,
double yCenter,
) {
canvas.save();
DateTime now = DateTime.now();
final innerPaint = Paint()
..color = Colors.black
..strokeWidth = 4.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final outerPaint = Paint()
..color = Colors.white
..strokeWidth = 6.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final secPaint = Paint()
..color = primaryColor
..strokeWidth = 1.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final rootPaint = Paint()
..color = Colors.white
..strokeWidth = 3.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.square;
final rootOrangeCirclePaint = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
final rootWhiteCirclePaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final rootBlackCirclePaint = Paint()
..color = Colors.black
..style = PaintingStyle.fill;
canvas.save();
canvas.rotate(2 * pi / 3);
final rootLength = 16.0;
// 분침
canvas.drawLine(Offset(0.0, 0.0), Offset(0.0, rootLength), rootPaint);
canvas.drawLine(
Offset(0.0, rootLength), Offset(0, xCenter * 0.9), outerPaint);
canvas.drawLine(
Offset(0.0, rootLength), Offset(0, xCenter * 0.9), innerPaint);
canvas.restore();
canvas.save();
canvas.rotate(2 * pi);
// 시침
canvas.drawLine(Offset(0.0, 0.0), Offset(0.0, rootLength), rootPaint);
canvas.drawLine(
Offset(0, rootLength), Offset(0, xCenter * 0.5), outerPaint);
canvas.drawLine(
Offset(0, rootLength), Offset(0, xCenter * 0.5), innerPaint);
canvas.restore();
canvas.save();
canvas.rotate(pi);
// 초침
canvas.drawLine(Offset(0, 0), Offset(0, xCenter * 0.9), secPaint);
canvas.drawCircle(Offset(0, 0), 6.0, rootWhiteCirclePaint);
canvas.drawCircle(Offset(0, 0), 4.0, rootOrangeCirclePaint);
canvas.drawCircle(Offset(0, 0), 2.0, rootBlackCirclePaint);
canvas.restore();
canvas.restore();
}
어떤가요? 이제 상당히 비슷해졌죠?
시계 작동시키기
이제는 시간에 맞춰서 시계가 작동하게 해볼거예요.
Screen.dart
class CustomPaintHermesAppleWatch extends StatefulWidget {
_CustomPaintHermesAppleWatchState createState() =>
_CustomPaintHermesAppleWatchState();
}
class _CustomPaintHermesAppleWatchState
extends State<CustomPaintHermesAppleWatch> {
DateTime now;
void initState() {
super.initState();
now = DateTime.now();
Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
now = DateTime.now();
});
});
}
Widget build(BuildContext context) {
return Scaffold(
body: Container(
alignment: Alignment.center,
color: Color(0xFF000000),
child: Container(
width: MediaQuery.of(context).size.width / 2,
child: AspectRatio(
aspectRatio: 2/3,
child: CustomPaint(
painter: WatchPainter(
now,
),
),
),
),
),
);
}
}
WatchPainter.dart
class WatchPainter extends CustomPainter {
final Color primaryColor = Color(0xFFE57242);
final Color bgColor = Color(0xFF000000);
final Color accentColor = Color(0xFFFFFFFF);
final DateTime now;
final TextPainter tp;
WatchPainter(DateTime now)
: this.tp = TextPainter(
textDirection: TextDirection.ltr,
),
this.now = now;
void paint(Canvas canvas, Size size) {
final xCenter = size.width / 2;
final yCenter = size.height / 2;
final angle = (2 * pi) / 12;
canvas.save();
canvas.translate(xCenter, yCenter);
renderTime(canvas, size, xCenter, yCenter, angle);
renderHands(canvas, size, xCenter, yCenter);
canvas.restore();
}
renderHands(
Canvas canvas,
Size size,
double xCenter,
double yCenter,
) {
canvas.save();
final innerPaint = Paint()
..color = Colors.black
..strokeWidth = 4.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final outerPaint = Paint()
..color = Colors.white
..strokeWidth = 6.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final secPaint = Paint()
..color = primaryColor
..strokeWidth = 1.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final rootPaint = Paint()
..color = Colors.white
..strokeWidth = 3.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.square;
final rootOrangeCirclePaint = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
final rootWhiteCirclePaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final rootBlackCirclePaint = Paint()
..color = Colors.black
..style = PaintingStyle.fill;
final hourRotation =
(now.hour % 12) * (2 * pi / 12) + now.minute * (2 * pi / (12 * 60));
final minuteRotation = now.minute * (2 * pi / 60);
final secondRotation = now.second * (2 * pi / 60);
final rootLength = 16.0;
// 시침
canvas.save();
canvas.rotate(hourRotation);
canvas.drawLine(Offset(0.0, 0.0), Offset(0.0, -rootLength), rootPaint);
canvas.drawLine(
Offset(0, -rootLength), Offset(0, -xCenter * 0.5), outerPaint);
canvas.drawLine(
Offset(0, -rootLength), Offset(0, -xCenter * 0.5), innerPaint);
canvas.restore();
// 분침
canvas.save();
canvas.rotate(minuteRotation);
canvas.drawLine(Offset(0.0, 0.0), Offset(0.0, -rootLength), rootPaint);
canvas.drawLine(
Offset(0.0, -rootLength), Offset(0, -xCenter * 0.9), outerPaint);
canvas.drawLine(
Offset(0.0, -rootLength), Offset(0, -xCenter * 0.9), innerPaint);
canvas.restore();
// 초침
canvas.save();
canvas.rotate(secondRotation);
canvas.drawLine(Offset(0, 0), Offset(0, -xCenter * 0.9), secPaint);
canvas.drawCircle(Offset(0, 0), 6.0, rootWhiteCirclePaint);
canvas.drawCircle(Offset(0, 0), 4.0, rootOrangeCirclePaint);
canvas.drawCircle(Offset(0, 0), 2.0, rootBlackCirclePaint);
canvas.restore();
canvas.restore();
}
renderTime(
Canvas canvas, Size size, double xCenter, double yCenter, double angle) {
canvas.save();
final vertLen = yCenter / cos(angle);
final horLen = xCenter / sin(angle * 2);
final lengthList = [
yCenter,
vertLen,
horLen,
xCenter,
horLen,
vertLen,
yCenter,
];
for (int i = 0; i < 12; i++) {
canvas.save();
final display = i == 0 ? '12' : i.toString();
canvas.translate(0.0, -lengthList[i % 6]);
tp.text = TextSpan(
text: display,
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold,
color: primaryColor,
),
);
// 글자가 rotation 만큼 돌아간걸
// 원상복귀 하는거예요
canvas.rotate(-angle * i);
tp.layout();
tp.paint(
canvas,
Offset(
-(tp.width / 2),
-(tp.height / 2),
),
);
canvas.restore();
canvas.rotate(angle);
}
canvas.restore();
}
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
rootLength
랑 xCenter
를 모두 -
로 변경한걸 유의해주세요.
renderTime
함수에서 저희가 -
를 사용했듯이 각도가 180도
돌아있는 상태예요!
끝
어떤가요? 아무래도 완전히 똑같진 않죠? 이건 제가 똥손이라 그런것 같아요.
어쨋든 이 강의를 끝까지 따라오셨다면 CustomPaint
에 대해
많이 배우셨을 것 같아요. 도움이 되셨다면 제 유튜브 채널도
구독과 좋아요 부탁드립니다!