Flutter: Custom PaintとCustomPainter
標準ウィジェットでは足りないとき、Canvasはあなたの白紙のキャンバス — どんなUIも一筆一筆で描けます。
📋 前提条件: 以下に既に慣れている必要があります
- レッスン15:アニメーションシステム
1. このレッスンで学ぶこと
- CustomPaint + CustomPainter:paint() / shouldRepaint()ライフサイクル
- Canvas API:drawLine、drawRect、drawCircle、drawPath、drawParagraph
- ジェスチャー統合:GestureDetector + CustomPainterによる署名パッド
- パフォーマンス最適化:精密なshouldRepaintチェック + RepaintBoundary分離
- ShopApp:売上データ折れ線グラフ + 手書き署名コンポーネント
2. チャート要件のリアルなストーリー
(1) 悩み:サードパーティチャートライブラリのカスタマイズ不足
BobのShopAppには売上推移折れ線グラフが必要で、要件は:グラデーション塗りつぶし、データポイントタップで詳細表示、アニメーション描画。サードパーティチャートライブラリはグラデーション未対応、タップコールバックが柔軟でない、アニメーションが制御不能のいずれかでした。fl_chartで80%までは到達しましたが、残り20%のソースコード修正ができませんでした。
(2) CustomPainterソリューション
CustomPainterは完全な描画コントロールを提供 — すべてのストローク、すべてのピクセルが自分次第です。
DART
import 'package:flutter/material.dart';
// Custom class definition source:
// - data: List<double> type, representing sales data points
class SalesChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Full control: draw exactly what you need
final linePaint = Paint()..color = Colors.blue..strokeWidth = 2..style = PaintingStyle.stroke;
final fillPaint = Paint()..shader = LinearGradient(colors: [Colors.blue.withOpacity(0.3), Colors.transparent])
.createShader(Rect.fromLTWH(0, 0, size.width, size.height));
// Draw path, fill, dots, labels...
}
@override
bool shouldRepaint(covariant SalesChartPainter old) => old.data != data;
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 成果:100%カスタマイズ + サードパーティ依存ゼロ
BobはCustomPainterでわずか200行で全要件を実装し、サードパーティ依存ゼロでアニメーションも完全制御可能に。
3. CustomPainterシステム
graph LR
CP[CustomPaint] --> Painter[CustomPainter]
Painter --> Canvas[Canvas API]
Canvas --> DL[drawLine]
Canvas --> DR2[drawRect]
Canvas --> DC[drawCircle]
Canvas --> DP2[drawPath]
Painter --> |shouldRepaint| RP[Repaint Check]
CP --> RB[RepaintBoundary]
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) CustomPainterライフサイクル
| メソッド | トリガーのタイミング | 目的 |
|---|---|---|
paint() |
初回描画時、またはshouldRepaintがtrueを返した時 | 描画コマンドを実行 |
shouldRepaint() |
古いPainterと新しいPainterを比較 | 再描画するかどうかを決定 |
repaint() |
CustomPaintに再描画が必要なことを通知 | AnimationControllerと連携 |
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:基本的なCustomPainter
DART
import 'package:flutter/material.dart';
class PriceIndicatorPainter extends CustomPainter {
final double price;
final double maxPrice;
final Color color;
PriceIndicatorPainter({
required this.price,
required this.maxPrice,
this.color = Colors.green,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..color = color.withOpacity(0.2)
..style = PaintingStyle.fill;
// Calculate bar height
final barHeight = (price / maxPrice) * size.height;
final rect = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
// Draw filled bar
canvas.drawRect(rect, fillPaint);
// Draw border
canvas.drawRect(rect, paint);
// Draw price text
final textSpan = TextSpan(text: '\$${price.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 12));
final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, size.height - barHeight - 16));
}
@override
bool shouldRepaint(covariant PriceIndicatorPainter old) =>
old.price != price || old.maxPrice != maxPrice;
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
4. Canvas API詳解
(1) 一般的な描画メソッド
| メソッド | 説明 | パラメータ |
|---|---|---|
drawLine |
線を描画 | p1, p2, paint |
drawRect |
矩形を描画 | rect, paint |
drawRRect |
角丸矩形を描画 | rrect, paint |
drawCircle |
円を描画 | center, radius, paint |
drawOval |
楕円を描画 | rect, paint |
drawArc |
弧を描画 | rect, startAngle, sweepAngle, useCenter, paint |
drawPath |
パスを描画 | path, paint |
drawShadow |
影を描画 | path, color, elevation, transparentOccluder |
drawParagraph |
テキストを描画 | paragraph, offset |
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:グラデーション折れ線グラフの描画
DART
import 'package:flutter/material.dart';
class LineChartPainter extends CustomPainter {
final List<double> dataPoints;
final Color lineColor;
final double maxValue;
LineChartPainter({
required this.dataPoints,
this.lineColor = Colors.blue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
if (dataPoints.isEmpty) return;
final linePaint = Paint()
..color = lineColor
..strokeWidth = 2.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final dotPaint = Paint()
..color = lineColor
..style = PaintingStyle.fill;
final gridPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 0.5;
// Draw grid lines
for (int i = 0; i <= 4; i++) {
final y = size.height * i / 4;
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Calculate points
final points = <Offset>[];
for (int i = 0; i < dataPoints.length; i++) {
final x = (i / (dataPoints.length - 1)) * size.width;
final y = size.height - (dataPoints[i] / maxValue) * size.height;
points.add(Offset(x, y));
}
// Draw gradient fill
final fillPath = Path()
..moveTo(points.first.dx, size.height)
..lineTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
fillPath.lineTo(points[i].dx, points[i].dy);
}
fillPath.lineTo(points.last.dx, size.height);
fillPath.close();
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.05)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawPath(fillPath, fillPaint);
// Draw line
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(linePath, linePaint);
// Draw dots
for (final point in points) {
canvas.drawCircle(point, 4, dotPaint);
}
}
@override
bool shouldRepaint(covariant LineChartPainter old) =>
old.dataPoints != dataPoints || old.maxValue != maxValue;
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. ジェスチャー統合:署名パッド
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:SignaturePadコンポーネント
DART
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class SignaturePad extends StatefulWidget {
final ValueChanged<ui.Image>? onSignatureComplete;
const SignaturePad({super.key, this.onSignatureComplete});
@override
State<SignaturePad> createState() => _SignaturePadState();
}
class _SignaturePadState extends State<SignaturePad> {
final List<Offset?> _points = [];
final _painterKey = GlobalKey();
void _onPointerDown(PointerDownEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerMove(PointerMoveEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerUp(PointerUpEvent event) {
setState(() => _points.add(null)); // null = line break
}
void _clear() => setState(() => _points.clear());
@override
Widget build(BuildContext context) {
return Column(children: [
Container(
height: 200,
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(key: _painterKey, size: Size.infinite, painter: SignaturePainter(_points)),
),
),
),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: _clear, child: const Text('Clear')),
const SizedBox(width: 8),
FilledButton(onPressed: () {
// Capture signature as image
widget.onSignatureComplete?.call(null); // Simplified
}, child: const Text('Confirm')),
]),
]);
}
}
class SignaturePainter extends CustomPainter {
final List<Offset?> points;
SignaturePainter(this.points);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 2.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(covariant SignaturePainter old) => old.points != points;
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. パフォーマンス最適化
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:RepaintBoundary再描画分離
DART
import 'package:flutter/material.dart';
// Custom class definition sources:
// - ProductHeader/ProductDescription: custom Widgets
// - LineChartPainter: see Section 4 in this lesson
// - salesData: List<double> type
// Without RepaintBoundary: entire page repaints on chart update
Column(children: [
ProductHeader(product: product),
RepaintBoundary( // Only chart repaints
child: CustomPaint(painter: LineChartPainter(data: salesData)),
),
ProductDescription(product: product), // NOT repainted
])
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| 最適化手法 | 効果 |
|---|---|
shouldRepaint精密チェック |
不要な再描画を回避 |
RepaintBoundary |
再描画スコープを分離 |
PictureRecorderキャッシュ |
複雑なグラフィックをImageとしてキャッシュ |
| paint内でのオブジェクト生成を回避 | GC負荷を軽減 |
7. 完全な例:ShopApp売上チャートコンポーネント
DART
import 'package:flutter/material.dart';
// Custom class definition sources:
// - LineChartPainter: see Section 4 in this lesson
// - SalesData: see definition below
class SalesChart extends StatelessWidget {
final List<SalesData> data;
final double width;
final double height;
const SalesChart({super.key, required this.data, this.width = 350, this.height = 200});
@override
Widget build(BuildContext context) {
final maxRevenue = data.map((d) => d.revenue).reduce((a, b) => a > b ? a : b);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Monthly Revenue (USD)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
SizedBox(width: width, height: height,
child: RepaintBoundary(child: CustomPaint(
painter: LineChartPainter(
dataPoints: data.map((d) => d.revenue).toList(),
maxValue: maxRevenue * 1.1,
lineColor: Theme.of(context).colorScheme.primary,
),
size: Size(width, height),
)),
),
const SizedBox(height: 8),
// Legend
Wrap(spacing: 16, children: data.take(6).map((d) => Text(
'${d.month}: \$${(d.revenue / 1000).toStringAsFixed(0)}K',
style: const TextStyle(fontSize: 11),
)).toList()),
]),
),
);
}
}
class SalesData {
final String month;
final double revenue;
const SalesData({required this.month, required this.revenue});
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q CustomPainterはいつ再描画する必要がありますか?
A Painterのプロパティが変更された時。shouldRepaintを正しく実装する必要があります:新旧プロパティを比較し、変更があればtrueを返します。
Q CustomPaintのサイズはどう決まりますか?
A 子がある場合、CustomPaintは子のサイズに従います。子がない場合、sizeプロパティを使用するか、親でSizedBoxで制約します。
Q Canvasで角丸矩形を描くには?
A
drawRRect(RRect.fromRectAndRadius(rect, radius), paint)を使用します。Q 署名パッドを画像として保存するには?
A
ui.PictureRecorder()でCanvas描画を記録し、endRecording().toImage()でui.Imageに変換します。Q shouldRepaintで常にtrueを返すとどうなりますか?
A 毎回のbuildで再描画がトリガーされ、データが変わっていなくても再描画されます。シンプルなコンポーネントでは問題ありませんが、複雑なチャートではカクつきの原因になります。
Q なぜpaint内でTextではなくdrawParagraphを使うのですか?
A Canvasはウィジェットを直接描画できません。テキストはTextPainter/Paragraphでレイアウトしてからpaint(canvas, offset)で描画する必要があります。
📖 まとめ
- CustomPainter + paint()が完全な描画コントロールを提供、shouldRepaintが再描画を制御
- Canvas APIは豊富:線/矩形/円/パス/テキスト/影/グラデーション
- Listener/Pointerイベントでジェスチャーベースの描画が可能(署名パッド)
- RepaintBoundaryが再描画スコープを分離、shouldRepaint精密チェックで無駄を回避
- 折れ線グラフはCustomPainterの最も典型的な実世界応用
📝 練習問題
- 基本 (⭐):CustomPainterを使って円形プログレスバー(0-100%)を描画し、緑から赤へのカラーグラデーションを適用してください。
- 中級 (⭐⭐):グリッド線、グラデーション塗りつぶし、データポイントマーカー付きの売上折れ線グラフを実装し、データポイントタップで詳細を表示するツールチップを追加してください。
- チャレンジ (⭐⭐⭐):完全な手書き署名コンポーネントを実装してください:タッチ描画 + クリア + 画像として保存 + 速度に応じた線幅変化。