web analytics

รู้จักกับ Custom Painting ใน Flutter ตอนที่ 2

สวัสดีครับ บล็อกนี้จะพาไปลองเล่น Custom Painting ใน Flutter กันต่อครับ ตอนนี้เป็นตอนที่ 2 แล้ว หลังจากที่ตอนที่ 1 เราได้รู้จักกับการวาดรูปทรงแบบพื้นฐานบน canvas แล้ว ในตอนที่ 2 นี้เราจะมาลองเล่นความสามารถในการวาดที่ซับซ้อนขึ้นครับ

หากยังไม่ได้อ่านตอนที่ 1 อ่านได้ที่ลิงค์ข้างล่างนี้เลยครับ

Path

การวาดที่รูปทรงเดี่ยวๆอาจจะไม่ตอบโจทย์ที่เราต้องการ เราสามารถลากเส้นได้เหมือนใช้ปากกา มันคือ Path เช่น การวาดสามเหลี่ยม

การใช้ path ก็ไม่ยากเลย โดยเราจะต้องกำหนดจุดเริ่มให้มันก่อน แล้วกำหนดพิกัดเพื่อลากเส้นไป ในตัวอย่างนี้ ผมใช้ชื่อ class ว่า MyTrianglePainter

โดยสั่งให้ moveTo(W/2,0) ก็คือจิ้มปากกาไปที่พิกัด W/2 , 0
lineTo คือลากเส้นไปยังพิกัดนั้น เราก็ลากไป 2 จุด ก็ได้สามเหลี่ยมแล้ว

class MyTrianglePainter extends CustomPainter {
  Color color;

  MyTrianglePainter({@required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    Paint paint = Paint()
      ..color = color;

    Path pathTriangle = Path()
      ..moveTo(W / 2, 0)
      ..lineTo(0, H)
      ..lineTo(W, H)
      ..close();
    canvas.drawPath(pathTriangle, paint);
  }

  @override
  bool shouldRepaint(MyTrianglePainter oldDelegate) {
    return oldDelegate.color != color;
  }
}

กำหนด MyTrianglePainter ให้กับ CustomPaint

Center(
        child: CustomPaint(
          size: Size(150,150),
          painter: MyTrianglePainter(color: Colors.pink[300]),
        ),
      )

ได้รูปสามเหลี่ยมแล้ว

Clipping

หลังจากที่เรารู้จักการใช้ path แล้ว เทคนิคต่อมาคือการตัด ในตัวอย่างนี้ผมจะตัดสามเหลี่ยมโดยใช้วงกลมมาทับ จากนั้นเอาเฉพาะส่วนที่ทับซ้อนกันกับสามเหลี่ยม

ซึ่งสิ่งที่ต้องรู้คือ Clipping ในที่นี้คือ การตัดกระดาษ (canvas) และเราจะต้อง clip ก่อนวาดเท่านั้น ไม่สามารถวาดก่อน clip ได้ ดังนั้นการ clipping วงกลม มันคือการตัดกระดาษเป็นรูปวงกลมนั่นเอง

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    // clipping circle
    Path pathCircle = Path()
      ..addOval(Rect.fromCircle(center: Offset(W / 2, H / 2), radius: W / 2))
      ..close();
    canvas.clipPath(pathCircle);
 
    // draw triangle.
    Paint paintTriangle = Paint()
    ..color = color;

    Path pathTriangle = Path()
      ..moveTo(W / 2, 0)
      ..lineTo(0, H)
      ..lineTo(W, H)
      ..close();
    canvas.drawPath(pathTriangle, paintTriangle);
  }

จากนั้นผมจะลองวาดสี่เหลี่ยมลงไป ขนาด W/2 * H/2 และวางไว้ที่มุมล่างขวาของ canvas แบบนี้ตามรูป

วาด Rect เพิ่มลงไป ที่พิกัด W/2 , H/2 ขนาด W/2 * H/2

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    ...

    // drawing a rect.
    Paint paintRect = Paint()..color = Colors.orange[300];
    canvas.drawRect(Rect.fromLTWH(W/2, H/2, W/2, H/2), paintRect);
  }

แต่ปรากฏว่าผลที่ได้คือ มันไม่ใช่สี่เหลี่ยม มันออกจะเป็นวงกลมซะงั้น นั่นก็เพราะว่าอย่างที่กล่าวไปแล้วว่า Clipping มันคือการ clip ตัว canvas ดังนั้น เรา clip canvas เป็นวงกลมไปแล้ว canvas มันคือวงกลมไปแล้ว เลยออกมาเป็นดังรูปนั่นเอง

Save & Restore

จากที่เรา clip canvas แล้วทำให้เราไม่สามารถวาดรูปทรง นอก Clipping ได้ จึงมีวิธีการ สำหรับ save & restore มันคือการเก็บค่า state บน canvas เอาไว้ เพื่อให้เราทำบางสิ่ง จากนั้นค่อยคืนค่า ถ้าให้เปรียบเทียบ ก็เหมือนกับการนำกระดาษ (canvas) มาเพิ่มอีก 1 ชิ้น ทำให้เกิด Layer นั่นเอง ทำให้การวาดกับ clipping ไม่ส่งผลกับอีก layer นึง

และทุกครั้งที่เรา save เราจะต้อง restore เสมอ มันจะมาคู่กัน ถ้าไม่มีคู่จะ error ทันที ทีนี้เราก็ save & restore ในส่วน clip canvas วงกลมก่อน จากนั้นเราก็ค่อยวาดสี่เหลี่ยมตามที่เราต้องการ

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.save();
    ... // Clip circle and draw triangle.
    canvas.restore();

    // drawing a rect.
    Paint paintRect = Paint()..color = Colors.orange[300];
    canvas.drawRect(Rect.fromLTWH(W/2, H/2, W/2, H/2), paintRect);
  }

ได้แล้ว เพราะเรา แยก layer clipping ออกจากการวาดสี่เหลี่ยม นั่นเอง

Save Layer

การเรียก save() นั้น จะเป็นการ save state ทั้ง canvas เลย แต่หากเราต้องการ save เฉพาะส่วนที่เราต้องการ จะกายเป็นการ clip ไปโดยปริยาย เราสามารถใช้ saveLayer() และกำหนดขนาดที่ต้องการ เช่น ในตัวอย่างนี้หลังจากที่ผมได้รูปที่ต้องการแล้ว ผมจะแบ่ง canvas เป็น 9 ส่วน และจะเอาเฉพาะส่วนตรงกลาง 3 ส่วน

ผมสามารถใช้ saveLayer ได้ โดยกำหนด Rect ขนาดเป็น Rect.fromLTWH(0, H / 3, W, H / 3)

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.saveLayer(Rect.fromLTWH(0, H / 3, W, H / 3), Paint());
    
    // clip circle and draw triangle.
    canvas.save();
    ... 
    canvas.restore();
    
    // draw Rect.
    Paint paintRect = Paint()..color = Colors.orange[300];
    canvas.drawRect(Rect.fromLTWH(W/2, H/2, W/2, H/2), paintRect);

    canvas.restore();
  }

ได้รูปตามต้องการแล้วละ

Path combine

มาดูอีกเทคนิคนึงที่น่าจะใช้บ่อยๆครับ นั่นก็คือการเอารูปทรง 2 อันมา combine กัน เช่น ผมมีวงกลมซ้อนกัน 2 ชิ้น

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    Path pathCircle1 = Path()..addOval(Rect.fromCircle(center: Offset(W / 3, H / 2), radius: W / 2));
    Path pathCircle2 = Path()..addOval(Rect.fromCircle(center: Offset(W / 1, H / 2), radius: W / 2));
    canvas.drawPath(pathCircle1, Paint()..color = Colors.pink[300]);
    canvas.drawPath(pathCircle2, Paint()..color = Colors.orange[300]);
  }

เราสามารถใช้ Path.combine เพื่อเลือกเฉพาะส่วนที่ combine กันได้ เช่น จะเอาเฉพาะส่วนที่อยู่ในรูปทรงที่ 1 แต่อยู่ในรูปที่ 2

    Path pathCircle1 = Path()..addOval(Rect.fromCircle(center: Offset(W / 3, H / 2), radius: W / 2));
    Path pathCircle2 = Path()..addOval(Rect.fromCircle(center: Offset(W / 1, H / 2), radius: W / 2));

    Path pathDiff = Path.combine(PathOperation.difference, pathCircle1, pathCircle2);
    canvas.drawPath(pathDiff , Paint()..color = Colors.blue[300]);

Path combine มีอยู่ 5 แบบดังนี้

ดังนั้น การทำรูป Donut ก็สามารถทำได้ง่ายๆ โดยใช้ Path combine นั่นเอง

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    Path pathCircleOuter = Path()..addOval(Rect.fromCircle(center: Offset(W / 2, H / 2), radius: W / 2));
    Path pathCircleInner = Path()..addOval(Rect.fromCircle(center: Offset(W / 2, H / 2), radius: W / 4));

    Path pathDonut = Path.combine(PathOperation.difference, pathCircleOuter, pathCircleInner);

    canvas.drawPath(pathDonut, Paint()..color = Colors.pink[300]);
  }

วาดรูปแตงโมกัน

มาทำโจทย์เล่นๆกันครับ คือการวาดรูปแตงโมให้ออกมาประมาณนี้

ผมใช้ชื่อ class ว่า MyWaterMelonPainter นะ
เริ่มจากวาดรูปวงกลมซ้อนกัน 2 อัน วงนอกสีเขียว วงข้างในสีแดง และให้วงด้านในเล็กกว่าวงด้านนอกประมาณ W/10 จะได้เป็นเปลือกแตงโมเขียวๆนั่นเอง

class MyWaterMelonPainter extends CustomPainter {

  MyWaterMelonPainter();

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    Paint paintWaterMelonBody = Paint()..color = Color(0xff78bd53);
    canvas.drawCircle(Offset(W/2,H/2), W/2,paintWaterMelonBody) ;

    Paint paintWaterMelonRind = Paint()..color = Color(0xffe23342);
    canvas.drawCircle(Offset(W/2,H/2), (W/2) - W/10,paintWaterMelonRind) ;
  }

  @override
  bool shouldRepaint(MyWaterMelonPainter oldDelegate) {
    return true;
  }
}

กำหนด MyWaterMelonPainter ให้ CustomPaint ให้ขนาด 250*250 ละกัน

Center(
        child: CustomPaint(
          size: Size(250,250),
          painter: MyWaterMelonPainter(),
        ),
      )

ได้ลูกแตงโมแล้วละ

จากนั้นเราจะหั่นครึ่งแตงโม โดย Clipping ครึ่งหนึ่ง จะเอาส่วนครึ่งบนหรือส่วนครึ่งล่างก็ได้ ผมเอาส่วนครึ่งบนละกัน และในตัวอย่างนี้ผมเพิ่ม background ลงไปด้วยจะได้เห็นขนาดชัดๆ เพราะเดี๋ยวเราจะต้องหมุนแตงโม

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.save();
    canvas.clipRect(Rect.fromLTWH(0, 0, W, H / 2));
    canvas.drawPaint(Paint()..color = Colors.green[100]);
    Paint paintWaterMelonBody = Paint()..color = Color(0xff78bd53);
    canvas.drawCircle(Offset(W / 2, H / 2), W / 2, paintWaterMelonBody);
    Paint paintWaterMelonRind = Paint()..color = Color(0xffe23342);
    canvas.drawCircle(Offset(W / 2, H / 2), (W / 2) - W / 10, paintWaterMelonRind);
    canvas.restore();
  }

ได้แตงโมครึ่งลูกแล้ว

ต่อมาเราจะวาดส่วนเม็ดแตงโมกันครับ ในตัวอย่างนี้ผมจะขอวาดมั่วๆลงบริเวณเนื้อแตงโมสีแดงเลยนะ ขอแค่ให้มันไม่ทับและไม่ใกล้กันเกินไป ขนาดเม็ดแตงโมที่ใช้คือ W / 40

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    // draw watermelon body.
    canvas.save();
    ... 
    canvas.restore();

    canvas.save();
    Paint paintSeed = Paint()..color = Color(0xff3c3b41);
    double sizeSeed = W / 40;
    canvas.drawCircle(Offset((W / 2) - (W / 10), (H / 2) - (H / 10)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) - (W / 8), (H / 2) - (H / 4)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) - (W / 4), (H / 2) - (H / 8)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) + (W / 4), (H / 2) - (H / 8)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) + (W / 6), (H / 2) - (H / 5)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) + (W / 14), (H / 2) - (H / 7)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) + (W / 28), (H / 2) - (H / 3.5)), sizeSeed, paintSeed);
    canvas.drawCircle(Offset((W / 2) + (W / 32), (H / 2) - (H / 18)), sizeSeed, paintSeed);
    canvas.restore();
  }

ได้เม็ดแตงโมประมาณนี้

ต่อไปเราจะหมุนแตงโมกันครับ คำสั่งหมุนก็ง่ายๆเลยคือ canvas.rotate(radiant) โดยพารามิเตอร์คือ radiant นะ จำได้ใช่ไหม ว่า 360 degree = 2Pi ซึ่งการหมุนมันจะใช้จุดหมุนคือจุด 0,0 นะ เพื่อให้เห็นภาพชัดๆ ผมเลยใส่พื้นหลังและมาหมุนให้ดูเลย

และการหมุนจะต้องหมุน canvas ก่อนวาด เป็นหลักการเดียวกับ Clipping ครับ

@override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.save();
    {
      canvas.rotate(radiant);
    
      // draw watermelon body.
      canvas.save();
      ...
      canvas.restore();

      // draw seeds.
      canvas.save();
      ...
      canvas.restore();
    }
    canvas.restore();
  }

จะเห็นว่ามุมที่ผมต้องการ ถ้าเอาตามเหมือนต้นแบบเลย จะประมาณ 0.7Pi แต่ว่าพอเราหมุนแล้วจะทำให้รูปหลุด canvas ออกไป ดังนั้นเราจะต้องใช้ canvas.translate เพื่อเคลื่อนย้ายตำแหน่งครับ

หลังจากคำนวณแล้ว รูปแตงโมของผมจะต้อง หมุน 0.7 Pi และ transalte เราจะต้องแปลงให้อยู่ในรูปการคูณความกว้างและความสูง เพื่อให้มัน responsive ดังนั้นผมจะได้ translate แกน x -0.33 * W ได้จาก การเอา 84/250 เพราะผมใช้ขนาด canvas 250 และแกน y ประมาณ -H

 @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.save();
    canvas.rotate(0.7 * math.pi);
    canvas.translate(-0.33 * W, -1 * H);

    ...
    canvas.restore();
  }

ได้รูปแตงโมแล้ว ประมาณนี้ ok เลย

จากนั้นลบพื้นหลังที่ใส่ไว้ชั่วคราวออกและลองนำรูปแตงโมไปใช้ในหลายๆขนาด เพื่อดูว่ามันสามารถแสดงได้แบบ responsive หรือไม่

           Center(
              child: Wrap(
                children: [
                  CustomPaint(
                    size: Size(50, 50),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(100, 100),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(150, 150),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(200, 200),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(120, 120),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(250, 250),
                    painter: MyWaterMelonPainter(),
                  ),
                  CustomPaint(
                    size: Size(100, 100),
                    painter: MyWaterMelonPainter(),
                  ),
                ],
              ),
            )

สิ่งที่ได้คือ แตงโมๆๆ

นอกจากนี้เรายังสามารถกำหนด scale ได้ด้วย เช่นใหญ่ขึ้น 2 เท่า

  @override
  void paint(Canvas canvas, Size size) {
    double W = size.width;
    double H = size.height;

    canvas.save();
    canvas.rotate(0.7 * math.pi);
    canvas.translate(-0.336 * W, -1 * H);
    canvas.scale(2);   // Add scale
    ...
    canvas.restore();
  }

ก็จะได้แตงโมใหญ่ขึ้น

สรุป

ขอบคุณที่ติดตามอ่านตอนที่ 2 จนจบนะครับ สำหรับในบล็อกตอนที่ 2 นี้ เราได้รู้จักกับการใช้ Path , Clipping , Save & Restore , Path combine และเราก็ได้ลองวาดแตงโมกันครับ ที่มีเรื่อง rotate , translate ด้วย หวังว่าบทความทั้ง 2 ตอนนี้จะมีประโยชน์ช่วยให้ผู้อ่านมีความเข้าใจเรื่อง Custom painting นี้มากขึ้นครับ

สำหรับตอนถัดไป จะพาไปลองวาดรูป และลองนำ painter ไปใช้กับแอปกันครับว่าสามารถใช้งานได้ในรูปแบบไหนบ้าง ฝากติดตามตอนที่ 3 ด้วยนะครับ เร็วๆนี้

Source code ดูได้ที่ dartpad ครับ (:
https://dartpad.dev/182c37882455d389ab4212478a7bc028