[Flutter] CustomPaint でオリジナルのチャートを作る

Flutter ではたくさんのチャート描画ライブラリが揃っており、基本的なものであればサクッと実装できてしまいます。

https://zenn.dev/tsuruo/articles/a8fc96ff5aa43a

しかしながら、ちょっと凝ったデザインにしようと思ったり、機能的にちょっと痒いところに手が届かないなと思うケースもしばしばあります。
今回はライブラリに手を加えるのではなく、Flutter に標準で用意されている CustomPaintを利用して独自のチャートを作ってみようと思います。

## 作成したもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/1529dbbf-0670-5527-f030-8f76c03babc0.png)

この画面は、例えばレンタルスペースの期間ごとの空き状況を可視化するといった目的で利用されます。横長のバーの一つ一つが予約データに対応していて、利用開始〜終了までを表現しています。また、予約者名をバーの上に表示したり、ステータスによって色を変えたり、クリックしたときに予約詳細画面に遷移したり、、、と、ちょっと凝った機能が必要でした。
これらの機能についてどのように実装したのか、ポイントごとにまとめて解説します。


## 実装項目一覧

[1. 全体構成](#1-全体構成)
[2. 背景層](#2-背景層)
[3. データ層](#3-データ層)
[4. データのクリックイベント](#4-データのクリックイベント)


## 1. 全体構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/4fd2e9f6-b95b-744c-fa91-e963d7e66493.png)

こちらが全体構成のイメージ図です。チャート本体部は、軸・ラベルグリッドの背景層を最下層として、その上にデータ層を積み上げるStackとして実装しています。
そしてこの背景層とデータ層のそれぞれを、`CustomPaint`を使って描画しています。

また、筆者は Riverpodが大好きで、この画面全体の状態は `StateNotifierProvider` を使って管理しています。
`StateNotiferProvider` については以下の記事などを参照ください。

- 参考
    - 【Flutter】StateNotifierProviderで状態管理(Riverpod)
        - https://isub.co.jp/flutter/flutter-riverpod-statenotifierprovider/
    - StateNotifierProvider (公式)
        - https://riverpod.dev/ja/docs/providers/state_notifier_provider


## 2. 背景層

まず、軸やグリッドを描画するための背景層を作っていきます。

### 処理の流れ

処理の流れをざっくりと図示すると以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/fa4734ab-76cc-a6ad-9bef-327b068c2ff3.png)


背景層は軸とグリッド、およびラベルで構成されます。
この画面は予約情報の開始日〜終了日までを線分として表現したいので、X軸方向に乗る値のデータ型は `DateTime` としました。また、どのアドレスが埋まっているのかを表現したいので、Y軸方向に乗るデータ型は `int` としました（A~Zまでのアドレス一覧のindexに相当します）。

- stateに保持した `xMin`, `xMax` を入力します。
    - これらは 予約情報を表示したい期間を決定します。
- `xMin`, `xMax` から `Offset` を計算します（Offsetの考え方については後述）。
- 計算されたOffsetをもとに、X軸とX軸方向のグリッド線を描画します（描画方法については後述）。

Y軸方向もほとんど同様です。

- stateに保持した `Address` の配列からY軸方向の `Offset` を計算します。
- 計算されたOffsetをもとに、Y軸とY軸方向のグリッド線を描画します。


:::note info
ここからの内容は `CustomPaint` の基礎的な使い方が前提知識として必要になります。
以下の記事を参考に、実装のなんとなくのイメージを掴んでいただけるといいと思います。

- https://zenn.dev/faucon/articles/a24a5fd0a84494
:::



### Offsetの考え方

`Offset` とは、**ある任意の点**（原点）を基準として、そこからの距離を表すものです。
Flutterの `CustomPaint` では、左上が原点となります。
例えば下の図の (x1,y1) という点は、

```dart
const x1 = 20.0
const y1 = 10.0
x1y1 = Offset(x1, y1)
```

のように表現できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/58cd4b81-7e5d-dbbb-1fa9-41f4a2f8b462.png)

そして Offsetで定められた2点があれば、その間をつなぐ線分を描画できます（図の赤線です）。軸やグリッドは、Offsetの計算を線の数分だけ実行して、ループ処理で描画していきます。

### 描画の実装

では、描画の実装に入っていきます。今回描画するパーツは軸、ラベル、グリッドです。
それぞれのパーツがどのように配置されるかを示したのが下図です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/6bd040cb-9d25-9611-1f5e-e47ea5297a2a.png)


- XLabelRegionには、X軸のラベル が描画されます。
    - `2023/9/1` のように、一定間隔での日付の文字列です。
    - 文字列の間を十分に開けるために、45°程度回転させて描画するのが望ましいです。
- YLabelRegionには、Y軸のラベル が描画されます。
    - AtoZの文字列です。
- MainRegionには、軸の線分とグリッドの線分が描画されます。

ここでポイントとなるのが、 `labelRegionWidth`, `labelRegionHeight` です。
データが描画されることになるのがMainRegionですが、MainRegionの原点は、このWidgetの原点とは `labelRegionWidth`, `labelRegionHeight` の分だけ ズレたところになります。
このまま愚直に実装すると、以下のようになります。

```dart:あまりイケていない書き方
  @override
  void paint(Canvas canvas, Size size) {
    const labelRegionWidth = 40.0;
    const labelRegionHeight = 40.0;
    final double x1 = ...
    final double y1 = ...
    final double x2 = ...
    final double y2 = ...
    final offset1 = Offset(x1 + labelRegionWidth, y1 + labelRegionHeight)
    final offset2 = Offset(x2 + labelRegionWidth, y2 + labelRegionHeight)
    final linePainter = ...
    // 線分描画
    canvas.drawLine(offset1, offset2, linePainter);
```

Offsetにいちいち `labelRegionWidth` と `labelRegionWidth` を足してやらないといけなく、あまり美しくないですね。

ここで便利なのが、`canvas.translate` というメソッドです。

```dart:ちょっとイケている書き方
  @override
  void paint(Canvas canvas, Size size) {
    // label領域のwidth
    const labelRegionWidth = 40.0;
    const labelRegionHeight = 40.0;
    // MainRegionの左上までtranslate
    canvas.translate(labelRegionWidth, labelRegionHeight);
    final double x1 = ...
    final double y1 = ...
    final double x2 = ...
    final double y2 = ...
    final offset1 = Offset(x1, y1)  // 原点がMainRegionの左上になっている
    final offset2 = Offset(x2, y2)
    final linePainter = ...
    // 線分描画
    canvas.drawLine(offset1, offset2, linePainter);
```

`canvas.translate` は原点を移動するメソッドで、これを実行して `labelRegionWidth` と `labelRegionWidth` の分だけ原点移動しておくことで、 Offsetの計算のときに余計な項を足したり引いたりする必要がなくなります。また、`canvas.save`, `canvas.restore` というメソッドを使うことにより、 translate で移動した原点を保存したり、復旧させたりできます。
イメージは以下です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/4b52de0e-e46a-cfbd-a25f-1824e6d2cb80.png)

原点を移動させてからなにかを描画したのちに、元々の原点に復旧させたいといったときには、上記のメソッドを使いましょう。

このように原点移動と Offsetの計算を繰り返して、軸・ラベル・グリッドを描画します。
ソースコードの全体は以下のようになります。（クリックすると開きます。）

<details><summary>reservation_management_background.dart</summary>

```dart:reservation_management_background.dart
class ReservationManagementBackground extends ConsumerWidget {
  final Size size;
  final DateTime xMin;
  final DateTime xMax;
  final Duration xAxisInterval;
  final List<Address> addresses;
  const ReservationManagementBackground({
    Key? key,
    required this.size,
    required this.xMin,
    required this.xMax,
    required this.xAxisInterval,
    required this.addresses,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      color: Colors.white,
      width: size.width,
      height: size.height,
      child: CustomPaint(
        painter: ReservationManagementBackgroundPainter(
          xMin: xMin,
          xMax: xMax,
          xAxisInterval: xAxisInterval,
          addresses: addresses,
        ),
      ),
    );
  }
}

class ReservationManagementBackgroundPainter extends CustomPainter {
  final DateTime xMin;
  final DateTime xMax;
  final Duration xAxisInterval;
  final double? strokeWidth;
  final List<Address> addresses;

  ReservationManagementBackgroundPainter({
    required this.xMin,
    required this.xMax,
    required this.xAxisInterval,
    this.strokeWidth,
    required this.addresses,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final addressNames = addresses.map((e) => e.name ?? "不明").toList();
    // label領域のwidth
    const labelRegionWidth = 40.0;
    // label領域のheight
    const labelRegionHeight = 40.0;
    // paintできる領域
    final mainRegionSize = Size(
        size.width - labelRegionWidth * 2, size.height - labelRegionHeight * 2);
    // x軸のintervalのwidthを計算
    final xIntervalWidth = mainRegionSize.width /
        (xMax.difference(xMin).inSeconds / xAxisInterval.inSeconds);
    // x軸のintervalの数
    final xIntervalCount =
        xMax.difference(xMin).inSeconds / xAxisInterval.inSeconds;
    // y軸のintervalのheightを計算
    final yIntervalHeight = mainRegionSize.height / addressNames.length;
    // y軸のintervalの数
    final yIntervalCount = addressNames.length;
    canvas.save();
    Paint painter = Paint()
      ..color = Colors.grey.withOpacity(0.7)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;

    // 左上までtranslate
    canvas.translate(labelRegionWidth, labelRegionHeight);
    // 横線を描画
    for (int i = 0; i < yIntervalCount; i++) {
      canvas.drawLine(
          const Offset(0, 0), Offset(mainRegionSize.width, 0), painter);
      final yTextPainter = TextPainter(
        text: TextSpan(
          text: addressNames[i],
          style: const TextStyle(
            color: Colors.black,
            fontSize: 12,
            fontWeight: FontWeight.bold,
          ),
        ),
        textDirection: TextDirection.ltr,
      );
      yTextPainter.layout();
      yTextPainter.paint(canvas, const Offset(-20, -8.5));
      canvas.translate(0, yIntervalHeight);
    }
    canvas.restore();
    canvas.translate(labelRegionWidth, labelRegionWidth);
    canvas.save();
    // 縦線を描画
    for (int i = 0; i < xIntervalCount; i++) {
      canvas.drawLine(
          const Offset(0, 0), Offset(0, mainRegionSize.height), painter);
      canvas.save();
      // iに対応する日付を計算
      final iDate = xMin.add(Duration(seconds: i * xAxisInterval.inSeconds));
      // yyyy/MM/ddの形式で表示
      final iDateString =
          "${iDate.year.toString()}/${iDate.month.toString()}/${iDate.day.toString()}";
      final textPainter = TextPainter(
        text: TextSpan(
          text: iDateString,
          style: const TextStyle(
            color: Colors.black,
            fontSize: 10,
            fontWeight: FontWeight.bold,
          ),
        ),
        textDirection: TextDirection.ltr,
      );
      textPainter.layout();
      // 描画位置までtranslate
      canvas.translate(-20, mainRegionSize.height + 25);
      canvas.rotate(-pi / 4);
      textPainter.paint(canvas, const Offset(0, 0));
      canvas.restore();
      canvas.translate(xIntervalWidth, 0);
    }
    // 今日の日付を描画
    canvas.restore();
    final today = DateTime.now();
    final todayOffset = today.difference(xMin).inSeconds /
        xMax.difference(xMin).inSeconds *
        mainRegionSize.width;
    Paint todayPainter = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.5
      ..strokeCap = StrokeCap.round;
    canvas.drawLine(Offset(todayOffset, 0),
        Offset(todayOffset, mainRegionSize.height), todayPainter);
  }

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

</details>


## 3. データ層

次にデータ層を実装していきます。
データ層は、１層が `Reservation` の1つに対応しており、これを Stackで積み上げることによって画面上にすべての予約情報を描画しています。
1つの層にすべてのデータを描画することも可能ではありますが、クリックイベントを実装する都合上こうなりました。詳しくは後述します。

予約情報 `Researvation` は、以下のようなデータモデルで表現しています。


```dart:reservation.dart
part 'reservation.g.dart';
part 'reservation.freezed.dart';

@freezed
class Reservation with _$Reservation {
  // ignore: invalid_annotation_target
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory Reservation(
      {required String name,    // 予約者名
        @OrderStatusConverter() required ReservationStatus status,  // 予約状況
        required int reservationId,  // 予約ID
        required String addressId,  // 番地ID
        @OptionalDateTimeConverter() DateTime? since,  // 開始日
        @OptionalDateTimeConverter() DateTime? until,  // 終了日
      }) = _Reservation;

  factory Reservation.fromJson(Map<String, dynamic> json) =>
      _$ReservationFromJson(json);
}
```

- `since`, `until` がそれぞれ予約の開始日と終了日に対応します。
- その他のフィールドは、クリックイベントの入力に使われたり、ステータスによってデザインを変えたりするといった用途で使われます。

:::note warn
データモデルの実装には `freezed` を利用しています。馴染みのない方は以下を参照ください。
- https://zenn.dev/sae_eng/articles/ed68107fdae18d
- https://flutter.salon/plugin/jsonconverter/
:::


### 処理の流れ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/c724825f-7acb-41a6-fef1-e9624e0bc380.png)

- `Reservation` を1つ渡し、線分の始点と終点の Offset を `since`, `until`, および描画範囲の `xMin`, `xMax` から計算します。
- Offset から線分を描画します。
- データをクリックしたときのクリックイベントを登録します。


ここで、 `since`, `until` に対応する Offsetの計算方法を説明します。

```dart:reservation_mananegement_data_painter.dart (抜粋)
    // sinceとxminで大きい方を取る
    final since = reservation.since!.isAfter(xMin) ? reservation.since! : xMin;
    // untilとxmaxで小さい方を取る
    final until = reservation.until!.isBefore(xMax) ? reservation.until! : xMax;
    // sinceのoffset
    final sinceOffset = since.difference(xMin).inSeconds /
        xMax.difference(xMin).inSeconds *
        mainRegionSize.width;
    // untilのoffset
    final untilOffset = until.difference(xMin).inSeconds /
        xMax.difference(xMin).inSeconds *
        mainRegionSize.width;
```

まず `sinceとxminで大きい方を取る` という処理をしていますが、これは以下のように 元々の sinceが描画期間の開始日よりも前になっている場合に対策するためです。そのまま描画すると、MainRegionをはみ出してしまいます。そのため、since と xMinで大きい方を採用して、sinceを書き換えています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/c6e19f6a-505f-fe13-1af6-5af43395b6f2.png)

Offsetの計算は非常にシンプルで、描画期間（xMin~xMax）を 1 としたとき、since（またはuntil）の位置が 何割のところにあるかを求めて、 MainRegionの幅をかけてやるだけです。
これで since, untilのOffsetが求められます。
あとは、Offsetを使って線分を描画します。ここでは `Reservation` の `status` フィールドを参照して、線分の色を予約状況によって変える処理を入れています。また予約者名も線分上に描画したいので、`TextPainter` を使って描画します。

```dart:reservation_mananegement_data_painter.dart (抜粋)
    // 線分の描画
    Paint reservationPainter = Paint()
      ..color = getOrderStatusColor(status: reservation.status)
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;
    canvas.drawLine(Offset(sinceOffset, yIntervalHeight * addressIndex),
        Offset(untilOffset, yIntervalHeight * addressIndex), reservationPainter);

    // 線分上のテキストの描画
    final textPainter = TextPainter(
      text: TextSpan(children: [
        TextSpan(
          text: reservation.name,
          style: const TextStyle(
              color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
        )
      ]),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
        canvas,
        Offset(
            sinceOffset +
                (untilOffset - sinceOffset) / 2 -
                textPainter.width / 2,
            yIntervalHeight * addressIndex - textPainter.height / 2));
```

これで１つのデータが描画できました。これをデータの数の分だけ作成し、Stackを使って積み上げます。実装は以下のようになります。


```dart:reservation_management_view_body.dart
class ReservationManagementViewBody extends ConsumerStatefulWidget {
  const ReservationManagementViewBody({Key? key}) : super(key: key);

  @override
  ReservationManagementViewBodyState createState() =>
      ReservationManagementViewBodyState();
}

class ReservationManagementViewBodyState
    extends ConsumerState<ReservationManagementViewBody> {
  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    final state = ref.watch(reservationManagementViewStateNotifierProvider);
    const double headerHight = 60;
    return Column(
      children: [
        Container(
          height: headerHight,
          padding: const EdgeInsets.only(left: 30, right: 30),
          // ヘッダ部
          child: const ReservationManagementHeader(),
        ),
        Center(
          // 本体部
          child: Stack(
            alignment: Alignment.topCenter,
            children: <Widget>[
                  // 背景層
                  ReservationManagementBackground(
                    size: Size(
                      size.width * 0.95,
                      size.height * 0.8,
                    ),
                    xMin: state.since ?? DateTime(2021, 1, 1),
                    xMax: state.until ?? DateTime(2021, 12, 31),
                    xAxisInterval: const Duration(days: 30),
                    assemblyLines: state.assemblyLines,
                  ),
                ] +
                state.reservationManagements
                    // データ層
                    .map((e) => ReservationManagementData(
                          size: Size(
                            size.width * 0.95,
                            size.height * 0.8,
                          ),
                          xMin: state.since ?? DateTime(2024, 1, 1),
                          xMax: state.until ?? DateTime(2024, 12, 31),
                          xAxisInterval: const Duration(days: 30),
                          reservation: e,
                          addresses: state.addresses,
                        ))
                    .toList()
          ),
        ),
      ],
    );
  }
}
```

## 4. データのクリックイベント

最後に、予約情報をクリックしたときに予約詳細画面に遷移する、というクリックイベントを実装します。
`CustomPaint` のクリックイベントは、`GestureDetector` でラップすることで実現できます。

```dart:reservation_management_data.dart(抜粋)
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.size.width,
      height: widget.size.height,
      // GestureDetectorで CustomPaintをラップ
      child: GestureDetector(
          onTap: (widget.reservation.reservationId != null)
              ? () {
                  final url =
                      "${ref.read(htmlUrlProvider)}/#/reservation/${widget.reservation.reservationId}";
                  html.window.open(url, '');
                }
              : null,
          child: CustomPaint(
            painter: ReservationManagementDataPainter(
                xMin: widget.xMin,
                xMax: widget.xMax,
                xAxisInterval: widget.xAxisInterval,
                reservation: widget.reservation,
                addresses: widget.addresses,
                animation: animation),
          ),
        ),
    );
  }
```

ここで注意しないといけないのが、**データに対応する線分をクリックしたときだけクリックイベントが実行される**か？ということです。
GestureDetectorは CustomPaintの canvas全体に適用されるため、このままだと画面のどの部分をクリックしても `onTap` の処理が実行されてしまいます。また、Stackの最上部の層のクリックイベントのみが実行され、どこをクリックしても同じ予約情報の詳細に飛ぶことになります。

```dart:reservation_management_data_painter.dart(抜粋)
class ReservationManagementDataPainter extends CustomPainter {
  // 省略

  // これが重要!!
  Path? hitArea;

  ReservationManagementDataPainter({
    // 省略
  }) : super(repaint: animation);

  // この関数によって当たり判定領域を計算します。
  Path calculateHitArea(
      {required Offset startOffset,
      required Offset endOffset,
      required double strokeWidth}) {
    final path = Path();
    // topletf
    final topLeft = Offset(startOffset.dx, startOffset.dy - strokeWidth / 2);
    // bottomright
    final bottomRight = Offset(endOffset.dx, endOffset.dy + strokeWidth / 2);
    path.addRect(Rect.fromPoints(topLeft, bottomRight));
    return path;
  }

  @override
  void paint(Canvas canvas, Size size) {
    // 省略
    
    // hitAreaを計算
    hitArea = calculateHitArea(
        startOffset: Offset(sinceOffset, yIntervalHeight * addressIndex),
        endOffset: Offset(untilOffset, yIntervalHeight * addressIndex),
        strokeWidth: strokeWidth);
    
    // 省略
  }

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

  // クリックされたポイントが当たり判定領域かどうかを判定する。
  @override
  bool? hitTest(Offset position) {
    // translateした分を引く
    if (hitArea != null) {
      // 当たり判定に入れば、クリックイベントが実行される
      return hitArea!.contains(Offset(position.dx - 40, position.dy - 40));
    } else {
      // 判定外であれば何もしない
      return false;
    }
  }
}
```

重要なのが **hittest** という概念です。日本語だと **当たり判定** と訳すようです。
canvasがクリックされたとき（厳密にはmouseOverなども評価されます）、その位置がイベントを発生させるのに有効なのかどうかを判定できます。`CustomPaint` の hitTestをoverrideして、有効領域内(`hitArea`)のクリックなのかどうかを判定させます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/494791/34132ca2-b496-31cf-d984-3ad739c54509.png)

hitAreaはデータの線分よりも若干大きい `Path` として計算しています。これは実際には描画されない、論理的な領域です。Pathは引数にOffsetをとってその領域内かどうかを判定する `contain()` メソッドを持っているので利用します。

このように実装することで、Stackに積み上げても下層の有効領域は活きるため、データをクリックした際に適切な予約詳細情報の画面に遷移させることができます。


## まとめ

以上ですべての構成要素が実装できました。他に特定のデータにだけアニメーションを付けたりと、要望に応じてカスタマイズすることができます。もっと良い実装があるよ！という場合は、コメントで教えていただけると幸いです。