Flutter에서 CustomPaint를 어떻게 사용하나요?

질문

Flutter에서 CustomPaint 위젯과 커스텀 페인팅은 어떻게 사용하나요?

답변

Flutter의 CustomPaint 위젯은 개발자가 자유롭게 그래픽을 그릴 수 있는 캔버스를 제공합니다. 이를 통해 차트, 그래프, 커스텀 애니메이션, 게임 요소, 특수 효과 등 기본 위젯으로는 구현하기 어려운 복잡한 UI를 만들 수 있습니다.

CustomPaint의 기본 구조

CustomPaint 위젯은 두 가지 주요 구성 요소로 이루어져 있습니다:

  1. painter: 그림이 자식 위젯 뒤에 그려집니다.
  2. foregroundPainter: 그림이 자식 위젯 앞에 그려집니다.

이 두 속성은 모두 CustomPainter 클래스를 상속받은 객체를 요구합니다.

기본 사용법

CustomPaint(
  painter: MyPainter(),  // 배경 페인터
  foregroundPainter: MyForegroundPainter(),  // 전경 페인터
  child: Container(  // 선택적 자식 위젯
    width: 200,
    height: 200,
  ),
)

CustomPainter 구현하기

CustomPainter를 상속받아 커스텀 페인터 클래스를 만들어야 합니다:

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 그리기 코드 작성
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // 다시 그릴지 결정
    return false;
  }
}
  • paint 메서드: 이 메서드에서 실제 그리기 작업이 이루어집니다.
  • shouldRepaint 메서드: 위젯이 다시 빌드될 때 다시 그릴지 결정합니다.

그리기 도구: Paint 클래스

Paint 클래스는 선 두께, 색상, 스타일 등 그리기 속성을 정의합니다:

final paint = Paint()
  ..color = Colors.blue
  ..strokeWidth = 4
  ..style = PaintingStyle.stroke  // 테두리만 그리기
  // 또는
  // ..style = PaintingStyle.fill  // 내부 채우기
  ..strokeCap = StrokeCap.round;

기본 도형 그리기

Canvas 클래스는 다양한 기본 도형을 그리는 메서드를 제공합니다:

1. 선 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.black
    ..strokeWidth = 3;

  canvas.drawLine(
    Offset(0, 0),  // 시작점
    Offset(size.width, size.height),  // 끝점
    paint,
  );
}

2. 사각형 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.blue
    ..style = PaintingStyle.fill;

  final rect = Rect.fromLTWH(
    50,  // left
    50,  // top
    100,  // width
    100,  // height
  );

  canvas.drawRect(rect, paint);
}

3. 원 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.red
    ..style = PaintingStyle.fill;

  canvas.drawCircle(
    Offset(size.width / 2, size.height / 2),  // 중심점
    50,  // 반지름
    paint,
  );
}

4. 둥근 사각형 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.green
    ..style = PaintingStyle.fill;

  final rect = Rect.fromLTWH(50, 50, 100, 100);
  final radius = Radius.circular(20);

  canvas.drawRRect(
    RRect.fromRectAndRadius(rect, radius),
    paint,
  );
}

5. 타원 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.orange
    ..style = PaintingStyle.fill;

  final rect = Rect.fromLTWH(20, 50, 160, 80);

  canvas.drawOval(rect, paint);
}

6. 호(Arc) 그리기

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.purple
    ..style = PaintingStyle.stroke
    ..strokeWidth = 5;

  final rect = Rect.fromLTWH(50, 50, 100, 100);

  canvas.drawArc(
    rect,
    0,  // 시작 각도 (라디안)
    pi,  // 스윕 각도 (라디안)
    false,  // 중심에서 호까지 선을 그릴지 여부
    paint,
  );
}

경로(Path) 그리기

Path 클래스를 사용하면 복잡한 모양을 만들 수 있습니다:

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..color = Colors.blue
    ..style = PaintingStyle.stroke
    ..strokeWidth = 3;

  final path = Path();

  // 시작점 설정
  path.moveTo(0, size.height / 2);

  // 베지어 곡선 추가
  path.quadraticBezierTo(
    size.width / 2, 0,  // 제어점
    size.width, size.height / 2,  // 끝점
  );

  // 다른 베지어 곡선 추가
  path.quadraticBezierTo(
    size.width / 2, size.height,  // 제어점
    0, size.height / 2,  // 끝점
  );

  // 경로 닫기
  path.close();

  // 경로 그리기
  canvas.drawPath(path, paint);
}

그라데이션 사용하기

페인트에 그라데이션을 적용할 수 있습니다:

void paint(Canvas canvas, Size size) {
  final paint = Paint()
    ..shader = LinearGradient(
      colors: [Colors.blue, Colors.red],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));

  final rect = Rect.fromLTWH(0, 0, size.width, size.height);
  canvas.drawRect(rect, paint);
}

그림자 효과

그림자 효과를 구현하려면 PathPathEffect를 사용합니다:

void paint(Canvas canvas, Size size) {
  // 그림자 경로
  final shadowPath = Path()
    ..addRect(Rect.fromLTWH(50, 50, 100, 100));

  // 그림자 페인트
  final shadowPaint = Paint()
    ..color = Colors.black.withOpacity(0.3)
    ..maskFilter = MaskFilter.blur(BlurStyle.normal, 5);

  // 그림자 그리기
  canvas.drawPath(shadowPath, shadowPaint);

  // 실제 사각형 그리기
  final paint = Paint()
    ..color = Colors.blue;

  canvas.drawRect(Rect.fromLTWH(50, 50, 100, 100), paint);
}

텍스트 그리기

TextPainter를 사용하여 텍스트를 그릴 수 있습니다:

void paint(Canvas canvas, Size size) {
  final textStyle = TextStyle(
    color: Colors.black,
    fontSize: 24,
  );

  final textSpan = TextSpan(
    text: "Hello, Flutter!",
    style: textStyle,
  );

  final textPainter = TextPainter(
    text: textSpan,
    textDirection: TextDirection.ltr,
  );

  textPainter.layout(
    minWidth: 0,
    maxWidth: size.width,
  );

  final offset = Offset(
    (size.width - textPainter.width) / 2,
    (size.height - textPainter.height) / 2,
  );

  textPainter.paint(canvas, offset);
}

이미지 그리기

dart:ui 패키지의 Image 클래스를 사용하여 이미지를 그릴 수 있습니다. 이미지를 로드하는 과정이 비동기적이므로, 일반적으로 FutureBuilder나 상태 관리를 통해 이미지를 그립니다.

class ImagePainter extends CustomPainter {
  final ui.Image image;

  ImagePainter(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImage(
      image,
      Offset.zero,
      Paint(),
    );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

// 이미지 로드 및 사용 예시
Future<ui.Image> loadImage(String path) async {
  final data = await rootBundle.load(path);
  return await decodeImageFromList(data.buffer.asUint8List());
}

// 위젯에서 사용
class ImagePaintWidget extends StatefulWidget {
  @override
  _ImagePaintWidgetState createState() => _ImagePaintWidgetState();
}

class _ImagePaintWidgetState extends State<ImagePaintWidget> {
  ui.Image? _image;

  @override
  void initState() {
    super.initState();
    loadImage('assets/image.png').then((image) {
      setState(() {
        _image = image;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return _image == null
        ? CircularProgressIndicator()
        : CustomPaint(
            painter: ImagePainter(_image!),
            size: Size(200, 200),
          );
  }
}

애니메이션 결합하기

AnimationControllerCustomPainter를 결합하면 멋진 애니메이션 효과를 만들 수 있습니다:

class AnimatedCirclePainter extends CustomPainter {
  final Animation<double> animation;

  AnimatedCirclePainter(this.animation) : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = 50.0 * animation.value;

    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.fill;

    canvas.drawCircle(center, radius, paint);
  }

  @override
  bool shouldRepaint(covariant AnimatedCirclePainter oldDelegate) {
    return animation.value != oldDelegate.animation.value;
  }
}

// 사용 예시
class AnimatedCircleWidget extends StatefulWidget {
  @override
  _AnimatedCircleWidgetState createState() => _AnimatedCircleWidgetState();
}

class _AnimatedCircleWidgetState extends State<AnimatedCircleWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..repeat(reverse: true);

    _animation = Tween<double>(begin: 0.2, end: 1.0).animate(_controller);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: AnimatedCirclePainter(_animation),
      size: Size(200, 200),
    );
  }
}

실제 예시: 사용자 정의 진행 표시기

다음은 원형 진행 표시기를 만드는 예시입니다:

class CircularProgressPainter extends CustomPainter {
  final double progress; // 0.0부터 1.0까지의 값

  CircularProgressPainter({required this.progress});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = min(size.width, size.height) / 2;

    // 배경 원 그리기
    final backgroundPaint = Paint()
      ..color = Colors.grey.shade300
      ..style = PaintingStyle.stroke
      ..strokeWidth = 10;

    canvas.drawCircle(center, radius, backgroundPaint);

    // 진행 원호 그리기
    final progressPaint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 10
      ..strokeCap = StrokeCap.round;

    final progressAngle = 2 * pi * progress;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -pi / 2, // 12시 방향에서 시작
      progressAngle,
      false,
      progressPaint,
    );

    // 텍스트 그리기
    final percentage = (progress * 100).toInt();
    final textStyle = TextStyle(
      color: Colors.black,
      fontSize: 20,
      fontWeight: FontWeight.bold,
    );

    final textSpan = TextSpan(
      text: '$percentage%',
      style: textStyle,
    );

    final textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
    );

    textPainter.layout();

    final textOffset = Offset(
      center.dx - textPainter.width / 2,
      center.dy - textPainter.height / 2,
    );

    textPainter.paint(canvas, textOffset);
  }

  @override
  bool shouldRepaint(covariant CircularProgressPainter oldDelegate) {
    return progress != oldDelegate.progress;
  }
}

// 사용 예시
class ProgressIndicatorDemo extends StatefulWidget {
  @override
  _ProgressIndicatorDemoState createState() => _ProgressIndicatorDemoState();
}

class _ProgressIndicatorDemoState extends State<ProgressIndicatorDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  double _progress = 0.0;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 5),
      vsync: this,
    )..addListener(() {
        setState(() {
          _progress = _controller.value;
        });
      });

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        painter: CircularProgressPainter(progress: _progress),
        size: Size(200, 200),
      ),
    );
  }
}

실제 예시: 간단한 차트 그리기

다음은 막대 차트를 그리는 예시입니다:

class BarChartPainter extends CustomPainter {
  final List<double> data;
  final List<Color> colors;

  BarChartPainter({required this.data, required this.colors});

  @override
  void paint(Canvas canvas, Size size) {
    // 축 그리기
    final axisPaint = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    // x축
    canvas.drawLine(
      Offset(0, size.height),
      Offset(size.width, size.height),
      axisPaint,
    );

    // y축
    canvas.drawLine(
      Offset(0, 0),
      Offset(0, size.height),
      axisPaint,
    );

    if (data.isEmpty) return;

    // 막대 그리기
    final barWidth = size.width / data.length * 0.8;
    final spacing = size.width / data.length * 0.2;
    final maxData = data.reduce(max);

    for (int i = 0; i < data.length; i++) {
      final barHeight = (data[i] / maxData) * size.height * 0.9;
      final x = i * (barWidth + spacing) + spacing / 2;

      final barPaint = Paint()
        ..color = i < colors.length ? colors[i] : Colors.blue
        ..style = PaintingStyle.fill;

      canvas.drawRect(
        Rect.fromLTWH(x, size.height - barHeight, barWidth, barHeight),
        barPaint,
      );

      // 값 표시
      final textStyle = TextStyle(
        color: Colors.black,
        fontSize: 12,
      );

      final textSpan = TextSpan(
        text: data[i].toStringAsFixed(1),
        style: textStyle,
      );

      final textPainter = TextPainter(
        text: textSpan,
        textDirection: TextDirection.ltr,
      );

      textPainter.layout();

      final textX = x + (barWidth - textPainter.width) / 2;
      final textY = size.height - barHeight - textPainter.height - 5;

      textPainter.paint(canvas, Offset(textX, textY));
    }
  }

  @override
  bool shouldRepaint(covariant BarChartPainter oldDelegate) {
    return data != oldDelegate.data || colors != oldDelegate.colors;
  }
}

// 사용 예시
class BarChartDemo extends StatelessWidget {
  final List<double> data = [10, 30, 20, 40, 25];
  final List<Color> colors = [
    Colors.red,
    Colors.green,
    Colors.blue,
    Colors.orange,
    Colors.purple,
  ];

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: CustomPaint(
        painter: BarChartPainter(data: data, colors: colors),
        size: Size(double.infinity, 300),
      ),
    );
  }
}

CustomPaint 최적화 팁

  1. 캐싱: shouldRepaint 메서드를 제대로 구현하여 불필요한 다시 그리기를 방지합니다.

  2. RepaintBoundary 사용: 복잡한 그림이나 자주 변경되지 않는 그림은 RepaintBoundary로 래핑하여 성능을 향상시킵니다.

    RepaintBoundary(
      child: CustomPaint(
        painter: MyComplexPainter(),
      ),
    )
    
  3. 클리핑 활용: canvas.clipRect를 사용하여 필요한 부분만 그립니다.

  4. 적절한 페인트 스타일 선택: 상황에 맞는 적절한 PaintingStyle을 선택합니다.

결론

Flutter의 CustomPaint 위젯과 CustomPainter 클래스는 복잡한 그래픽, 차트, 사용자 정의 UI 요소 등을 구현하는 강력한 도구입니다. 기본 위젯으로는 불가능한 시각적 효과를 만들 수 있으며, 애니메이션과 결합하면 더욱 역동적인 사용자 경험을 제공할 수 있습니다.

CustomPainter를 사용할 때는 성능 최적화에 주의하고, 적절한 추상화 계층을 만들어 코드를 구조화하는 것이 중요합니다. 또한 shouldRepaint 메서드를 올바르게 구현하여 불필요한 리페인팅을 방지하는 것이 중요합니다.

results matching ""

    No results matching ""