Flutter 로 Hermes 애플워치 페이스 그려보기

Written by 코드팩토리 JC

1월 15, 2024

Fluttert로 Hermes 애플워치 페이스 그려보기

서론

이번 시간에는 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{
  @override
  void paint(Canvas canvas, Size size) {
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Dart

일단 아래와 같이 색상들을 먼저 지정 해줄게요

WatchPainter.dart

final Color primaryColor = Color(0xFFE57242);
final Color bgColor = Color(0xFF000000);
final Color accentColor = Color(0xFFFFFFFF);
Dart

대충 따온거라 정확한 색상인지는 잘 모르겠어요.

스크린 초기화

일단 스크린에 WatchPainter 클래스를 불러놓고 라이브 업데이트로 화면을 봐보도록 할게요. 배경은 검정색으로 하겠습니다.

Screen.dart

class CustomPaintHermesAppleWatch extends StatefulWidget {
  @override
  _CustomPaintHermesAppleWatchState createState() => _CustomPaintHermesAppleWatchState();
}

class _CustomPaintHermesAppleWatchState extends State<CustomPaintHermesAppleWatch> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        color: Color(0xFF000000),
        child: CustomPaint(
          painter: WatchPainter(),
        ),
      ),
    );
  }
}
Dart

숫자 그리기

시계의 숫자를 하나만 가온데에 그려볼게요. 안타깝게도 에르메스 폰트를 제가 소유하고 있지 않기 때문에 갓 애플의 기본 폰트를 사용하겠습니다.

WatchPainter.dart

  @override
  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);
  }
Dart

이렇게 가온데 에르메스 칼라의 숫자를 그렸어요

에르메스 시계 숫자

시간별 숫자 배치하기

숫자를 동그라미로 배치하는건 어렵지 않은데 위 에르메스 시계처럼 숫자를 배치하려고 하니 한번에 할 수 있을만한 공식이 안떠오르더라구요.. 나이먹고 머리가 굳어버린건가.. 어쨋든 그래서 그냥 위치를 수동으로 구해서 리스트에 집어 넣었습니다. 좋은 방법 아시면 알려주세용~

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,
        );

  @override
  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();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Dart

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(),
            ),
          ),
        ),
      ),
    );
  }
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;

    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();
  }
Dart

아래처럼 결과가 나왔어요.

에르메스 시계

섬세작업

에르메스 워치 페이스 사진을 잘 보면 사실 가온데 부분이 조금 더 얇아요. 이걸 한번 구현 해볼게요.

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();
  }
Dart

에르메스 시계

어떤가요? 이제 상당히 비슷해졌죠?

시계 작동시키기

이제는 시간에 맞춰서 시계가 작동하게 해볼거예요.

Screen.dart

class CustomPaintHermesAppleWatch extends StatefulWidget {
  @override
  _CustomPaintHermesAppleWatchState createState() =>
      _CustomPaintHermesAppleWatchState();
}

class _CustomPaintHermesAppleWatchState
    extends State<CustomPaintHermesAppleWatch> {
  DateTime now;

  @override
  void initState() {
    super.initState();

    now = DateTime.now();

    Timer.periodic(Duration(seconds: 1), (timer) {
      setState(() {
        now = DateTime.now();
      });
    });
  }

  @override
  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,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
Dart

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;

  @override
  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();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Dart

rootLength 랑 xCenter 를 모두 - 로 변경한걸 유의해주세요. renderTime 함수에서 저희가 - 를 사용했듯이 각도가 180도 돌아있는 상태예요!

어떤가요? 아무래도 완전히 똑같진 않죠? 이건 제가 똥손이라 그런것 같아요. 어쨋든 이 강의를 끝까지 따라오셨다면 CustomPaint 에 대해 많이 배우셨을 것 같아요. 도움이 되셨다면 제 유튜브 채널도 구독과 좋아요 부탁드립니다!

관련 포스트

플러터에서의 Immutable Programming: copyWith 함수 마스터하기!

플러터에서의 Immutable Programming: copyWith 함수 마스터하기!

서론 불변 프로그래밍: 현대 개발의 핵심 현대 소프트웨어 개발에서 불변 프로그래밍(Immutable Programming)의 중요성은 간과할 수 없는 요소입니다. 플러터(Flutter)에서도 마찬가지로 불변 프로그래밍 개념이 매우 중요하며, copyWith 함수는 이러한 불변성을 유지하는 데 핵심적인 역할을 합니다. 이 글에서는 플러터를 배우기 시작하는 개발자들에게 불변 프로그래밍의 중요성을 강조하고, copyWith 함수의 역할과 사용 방법에 대해 설명 해보겠습니다!...

ChatGPT가 이야기하는 2024년 개발자 로드맵

ChatGPT가 이야기하는 2024년 개발자 로드맵

서론 개발자의 여정을 시작하며 안녕하세요, 미래의 개발자 여러분! 오늘부터 시작하는 여러분의 개발 여정에 함께할 수 있어서 기쁩니다. 2023년은 기술이 매우 빠르게 변화하는 해였으며, 이러한 변화 속에서 개발자가 되기 위한 길은 더욱 다채롭고 흥미로워졌습니다. 이 로드맵은 초보자인 여러분이 개발의 세계에 첫발을 내딛는 데 필요한 기초부터 시작해, 점차 심화 단계로 나아가는 길을 안내해 드릴 것입니다. 백엔드 개발 이 글은 단순히 기술을 배우는 것 이상의 의미를 가집니다....

Flutter Freezed 플러그인! Entity Code Generation은 이거 하나로 끝!

Flutter Freezed 플러그인! Entity Code Generation은 이거 하나로 끝!

https://youtu.be/i5p6wXLAX7I 서론 Flutter 는 Code Generation 기능이 상당히 많이 활성화되어 있어요. 흔히들 많이 사용하는 json_serializable 라이브러리도 있고 retrofit 및 chopper 라이브러리도 있습니다. 오늘 알려드릴 freezed 또한 데이터 클래스에 편의 기능들을 제공해주는 code generation 라이브러리입니다. Freezed vs Json Serializable Code Generation 이라는...