Flutter에서 CustomPaint를 어떻게 사용하나요?
질문
Flutter에서 CustomPaint 위젯과 커스텀 페인팅은 어떻게 사용하나요?
답변
Flutter의 CustomPaint 위젯은 개발자가 자유롭게 그래픽을 그릴 수 있는 캔버스를 제공합니다. 이를 통해 차트, 그래프, 커스텀 애니메이션, 게임 요소, 특수 효과 등 기본 위젯으로는 구현하기 어려운 복잡한 UI를 만들 수 있습니다.
CustomPaint의 기본 구조
CustomPaint 위젯은 두 가지 주요 구성 요소로 이루어져 있습니다:
painter
: 그림이 자식 위젯 뒤에 그려집니다.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);
}
그림자 효과
그림자 효과를 구현하려면 Path
와 PathEffect
를 사용합니다:
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),
);
}
}
애니메이션 결합하기
AnimationController
와 CustomPainter
를 결합하면 멋진 애니메이션 효과를 만들 수 있습니다:
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 최적화 팁
캐싱:
shouldRepaint
메서드를 제대로 구현하여 불필요한 다시 그리기를 방지합니다.RepaintBoundary 사용: 복잡한 그림이나 자주 변경되지 않는 그림은
RepaintBoundary
로 래핑하여 성능을 향상시킵니다.RepaintBoundary( child: CustomPaint( painter: MyComplexPainter(), ), )
클리핑 활용:
canvas.clipRect
를 사용하여 필요한 부분만 그립니다.적절한 페인트 스타일 선택: 상황에 맞는 적절한
PaintingStyle
을 선택합니다.
결론
Flutter의 CustomPaint 위젯과 CustomPainter 클래스는 복잡한 그래픽, 차트, 사용자 정의 UI 요소 등을 구현하는 강력한 도구입니다. 기본 위젯으로는 불가능한 시각적 효과를 만들 수 있으며, 애니메이션과 결합하면 더욱 역동적인 사용자 경험을 제공할 수 있습니다.
CustomPainter를 사용할 때는 성능 최적화에 주의하고, 적절한 추상화 계층을 만들어 코드를 구조화하는 것이 중요합니다. 또한 shouldRepaint
메서드를 올바르게 구현하여 불필요한 리페인팅을 방지하는 것이 중요합니다.