본문 바로가기
Flutter

[Flutter] 그림을 그려보자! CustomPaint

by 아마도개발자 2024. 2. 1.

 

사이드 프로젝트를 하다가 네모난 카드에 배경 이미지를 넣을 일이 생겼다. 뭔가 구름모양 같은 곡선이미지면 괜찮지 않을까 하는 생각에 소스를 얻으려 구글을 돌아다녔다. 하지만 꽤 오랜 시간 구글을 뒤적거렸음에도 마음에 드는 이미지가 없었다. 그러다 문득 나는 화면에 UI를 그리고 있었는데, 왜 굳이 이미지 그림을 찾아다닌 걸까?

 

내가 필요한 간단한 background 그림을 그리려면 CustomPaint를 사용해서 충분히 그릴 수 있을 것 같았다. 덤으로 CustomPaint와 친해질 기회가 될 수도 있고.

 

우선 CustomPaint에 대해 알아보자. 

 

CustomPaint란

CustomPaint의 정의를 읽어보면 CustomPaint는 `paint phase`동안 그림을 그릴 캔버스를 제공해주는 위젯이다. CustomPaint가 동작할 때, 먼저 `painter`에게 그림을 그릴 것을 요청한다. 캔버스의 좌표 시스템은 CustomPaint객체의 좌표 시스템과 일치한다. painter는 주어진 크기의 영역을 기준으로 원점에서 시작하는 사각형 내에서 그림을 그리도록 한다. 

 

이해를 위해 Flutter공식 문서의 기본 예제를 살펴보자

CustomPaint(
  painter: Sky(),
  child: const Center(
    child: Text(
      'Once upon a time...',
      style: TextStyle(
        fontSize: 40.0,
        fontWeight: FontWeight.w900,
        color: Color(0xFFFFFFFF),
      ),
    ),
  ),
)

 

Sky는 CustomPainter에서 확장된 위젯이다. CustomPaint에서 painter로 Sky라는 그림을 그려, child의 Text를 꾸며주는 코드라고 볼 수 있다. 여기서 CustomPaint의 생성자를 확인해보면

 

그림을 그리는 painter, foregroundPainter 그리고 child까지 모두 nullable인 것을 확인 할 수 있다. 이 3가지 속성을 자유롭게 활용, 조합하여 그림을 그릴 수 있다는 것이다. child에는 Text,Container 등 평소에 자주 사용하던 위젯들을 사용하면 되고, painter에는 canvas 직접 그림을 그리기 위한 `CustomPainter`라는 클래스가 사용된다.

 

class ClouldBackground extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {

  }

  @override
  bool shouldRepaint(CustomPainter oldDelegeate) {
    return false;
  }
}

 

CustomPainter에서 canvas에 직접 그려보기 위해서는 2가지 함수를 선언해야 한다. 바로 paint와 shouldRepaint이다.

 

paint는 내가 그리고 싶은 그림에 대한 지시문이다. 캔버스의 어디에서, 무엇을, 어떻게 그릴지에 대한 모든 명령이 paint안에서 이루어진다.

shouldRepaint는 새로운 CustomPaint객체가 인스턴스로 생성될 때마다 호출된다. true이면 인스턴스가 생성되었을 때, 캔버스에 그림을 다시 그리고, false이면 이미 그렸던 그림을 계속 유지한다.

 

그럼 paint함수로 진짜 그림을 그려보자. 

 

첫 번째로 할 일은 paint함수안에 Paint객체를 만드는 것이다. Paint객체는 canvas에 그림을 그릴 때 색상, 라인 크기, 형태 등 스타일에 대한 지정하는 역할을 한다. 

 

class ClouldBackground extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint() 
      ..color = Colors.deepPurpleAccent  
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4.0;
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegeate) {
    return false;
  }
}

 

스타일 지정을 마쳤으면 이제 어떤 그림이든 그릴 준비가 된 것이다. paint가 매개변수로 받는 canvas에는 정말 다양한 도형들을 그릴 수 있도록 메서드가 준비되어 있다. 그 중 자주 쓰이는 3가지 도형을 가져와보았다.

  1. Lines
  2. Rectangles
  3. Paths

 

가장 간단한 '직선'을 그려보자.

직선에 필요한 2개의 점을 지정하고, canvas의 darwLine메서를 사용하면 직선을 그릴 수 있다.

*부모 위젯에 padding이 적용되어 있음

 

아주 간단하게 캔버스의 양 모서리 점을 잇는 직선이 완성되었다. 필요한 좌표를 더 찍어서 drawLine을 여러개 사용하여 도형을 그리는 것도 당연히 가능하다.

 

 

이번에는 사각형을 그려보자.

사각형을 그리기 위해서는 drawRect메서드를 사용하면 된다. Offset타입의 p1, p2라는 고정된 인자를 사용하는 drawLine과는 다르게 drawRect는 Rect형태의 매개변수를 넘겨줘야 하는데 Rect를 만드는데는 

fromLTRB, fromLTWH, fromCircle, fromCenter, fromPoints 5가지 생성자를 사용할 수 있다.

 

  1. fromLTRB: left, top, right, bottom 값으로 사각형을 생성
  2. fromLTWH: left, top 엣지와 width, height의 크기로 사각형을 생성
  3. fromCircle: Center 값을 기준으로 radius*2만큼 width와 height 크기를 결정하여 사각형을 생성
  4. fromCenter: Center값을 기준으로 width와 height만큼 크기를 결정하여 사각형을 생성
  5. fromPoints: 주어진 offset값 a,b로 최소크기의 사각형을 생성

 

class ClouldBackground extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.deepPurpleAccent
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4.0;
    Rect rect1 = Rect.fromLTRB(0, 0, size.width, size.height);
    Rect rect2 = Rect.fromLTWH(0, 0, 100, 100);
    Rect rect3 = Rect.fromCenter(
        center: Offset(size.width / 2, size.height / 2),
        width: 100,
        height: 100);
    Rect rect4 = Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2), radius: 50);
    Rect rect5 = Rect.fromPoints(Offset(0, 0), Offset(size.width, size.height));

    canvas.drawRect(rect1, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegeate) {
    return false;
  }
}

 

 

어떤 Rect를 넘겨주든, drawRect로 사각형을 그릴 수 있다.

 

fromLTRB로 만들어진 사각형

 

 

다음은 path이다. rect, line, circle는 단어만 봐도 단순하게 어떤 도형이 그려질지 예상할 수가 있다. 하지만 path는 어떻게 동작할지 바로 유추하기가 힘든 느낌이 있다. 

 

path는 말 그대로 `길`이다. 내가 붓을 들고 종이에 그림을 그리면 먹물이 남게 되듯이, 내가 지정한 그래픽 경로 그대로 그림을 그려주는 것이다.

 

다시 예제코드를 만들어보자.

 

class ClouldBackground extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.deepPurpleAccent
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4.0;

    Path path = Path()
      ..moveTo(20, 20) // 시작점
      ..lineTo(100, 50) // 선 추가
      ..quadraticBezierTo(150, 10, 200, 50) // 이차 베지어 곡선 추가
      ..cubicTo(250, 80, 300, 120, 200, 200) // 삼차 베지어 곡선 추가
      ..arcTo(Rect.fromCircle(center: Offset(150, 250), radius: 30), 0, 3.14,
          false) // 호(arc) 추가
      ..close(); // 닫기 (시작점과 끝점을 연결하여 도형 완성)

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegeate) {
    return false;
  }
}

 

이번에는 path를 만들어 다양한 메소드를 사용해 보았다. Path는 앞서 다뤘던 것들과는 다르게 모양이 정해져 있는 객체가 아니다. 어떤 그림을 그릴지는 붓을 쥔 내가 정의해야 한다. 

 

 

예제코드는 다양한 메소드를 보여주기 위해 작성한 것이므로 결과물은 제쳐두고, 눈여겨 보아야 할 것은 moveTo부터 close까지의 내가 지정한 경로들이 그림으로 나타난다는 것이다. 아무래도 앞선 도형들보다는 다루기가 힘들겠지만, 그렇기 때문에 더 자유도 높게 내가 원하는 그림을 그릴 수 있다. 

 

사실 백그라운드 이미지를 그릴 때는 ClipPath를 이용하는 것이 더 편해보이기도 한다. 하지만 CustomPainter에 대한 이해도가 있어야 ClipPath를 사용할 때도 이해를 하면서 사용할 수 있을 것이라고 생각해 초기 생각대로 CustomPainter로 카드의 배경을 그려보았다.

 

class ClouldBackground extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill
      ..strokeWidth = 2.0;

    Path path = Path()
      // Draws a line from left top corner to right bottom
      ..moveTo(size.width / 4, size.height)
      ..quadraticBezierTo(
          size.width / 2, size.height, size.width * 0.6, size.height * 0.6)
      ..quadraticBezierTo(size.width * 0.75, 0, size.width, 0)
      ..lineTo(size.width, size.height)
      ..lineTo(size.width / 4, size.height);

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegeate) {
    return false;
  }
}