Flutter: Custom PaintとCustomPainter

標準ウィジェットでは足りないとき、Canvasはあなたの白紙のキャンバス — どんなUIも一筆一筆で描けます。

📋 前提条件: 以下に既に慣れている必要があります

1. このレッスンで学ぶこと


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システム

100%
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)で描画する必要があります。

📖 まとめ


📝 練習問題

  1. 基本 (⭐):CustomPainterを使って円形プログレスバー(0-100%)を描画し、緑から赤へのカラーグラデーションを適用してください。
  2. 中級 (⭐⭐):グリッド線、グラデーション塗りつぶし、データポイントマーカー付きの売上折れ線グラフを実装し、データポイントタップで詳細を表示するツールチップを追加してください。
  3. チャレンジ (⭐⭐⭐):完全な手書き署名コンポーネントを実装してください:タッチ描画 + クリア + 画像として保存 + 速度に応じた線幅変化。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%