3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

作成したもの

image.png

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

実装項目一覧

1. 全体構成
2. 背景層
3. データ層
4. データのクリックイベント

1. 全体構成

image.png

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

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

2. 背景層

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

処理の流れ

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

image.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軸方向のグリッド線を描画します。

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

Offsetの考え方

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

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

のように表現できます。

image.png

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

描画の実装

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

image.png

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

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

あまりイケていない書き方
  @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にいちいち labelRegionWidthlabelRegionWidth を足してやらないといけなく、あまり美しくないですね。

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

ちょっとイケている書き方
  @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 は原点を移動するメソッドで、これを実行して labelRegionWidthlabelRegionWidth の分だけ原点移動しておくことで、 Offsetの計算のときに余計な項を足したり引いたりする必要がなくなります。また、canvas.save, canvas.restore というメソッドを使うことにより、 translate で移動した原点を保存したり、復旧させたりできます。
イメージは以下です。

image.png

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

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

reservation_management_background.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;
  }
}

3. データ層

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

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

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

データモデルの実装には freezed を利用しています。馴染みのない方は以下を参照ください。

処理の流れ

image.png

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

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

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

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

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));

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

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 でラップすることで実現できます。

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の最上部の層のクリックイベントのみが実行され、どこをクリックしても同じ予約情報の詳細に飛ぶことになります。

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

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

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

まとめ

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?