[Flutter] Flutter 로 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{
  
  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;
  }
}

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

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

©Code Factory All Rights Reserved