Flutter ではたくさんのチャート描画ライブラリが揃っており、基本的なものであればサクッと実装できてしまいます。
しかしながら、ちょっと凝ったデザインにしようと思ったり、機能的にちょっと痒いところに手が届かないなと思うケースもしばしばあります。
今回はライブラリに手を加えるのではなく、Flutter に標準で用意されている CustomPaintを利用して独自のチャートを作ってみようと思います。
作成したもの
この画面は、例えばレンタルスペースの期間ごとの空き状況を可視化するといった目的で利用されます。横長のバーの一つ一つが予約データに対応していて、利用開始〜終了までを表現しています。また、予約者名をバーの上に表示したり、ステータスによって色を変えたり、クリックしたときに予約詳細画面に遷移したり、、、と、ちょっと凝った機能が必要でした。
これらの機能についてどのように実装したのか、ポイントごとにまとめて解説します。
実装項目一覧
1. 全体構成
2. 背景層
3. データ層
4. データのクリックイベント
1. 全体構成
こちらが全体構成のイメージ図です。チャート本体部は、軸・ラベルグリッドの背景層を最下層として、その上にデータ層を積み上げるStackとして実装しています。
そしてこの背景層とデータ層のそれぞれを、CustomPaint
を使って描画しています。
また、筆者は Riverpodが大好きで、この画面全体の状態は StateNotifierProvider
を使って管理しています。
StateNotiferProvider
については以下の記事などを参照ください。
- 参考
- 【Flutter】StateNotifierProviderで状態管理(Riverpod)
- StateNotifierProvider (公式)
2. 背景層
まず、軸やグリッドを描画するための背景層を作っていきます。
処理の流れ
処理の流れをざっくりと図示すると以下のようになります。
背景層は軸とグリッド、およびラベルで構成されます。
この画面は予約情報の開始日〜終了日までを線分として表現したいので、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)
のように表現できます。
そして Offsetで定められた2点があれば、その間をつなぐ線分を描画できます(図の赤線です)。軸やグリッドは、Offsetの計算を線の数分だけ実行して、ループ処理で描画していきます。
描画の実装
では、描画の実装に入っていきます。今回描画するパーツは軸、ラベル、グリッドです。
それぞれのパーツがどのように配置されるかを示したのが下図です。
- 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にいちいち labelRegionWidth
と labelRegionWidth
を足してやらないといけなく、あまり美しくないですね。
ここで便利なのが、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
は原点を移動するメソッドで、これを実行して labelRegionWidth
と labelRegionWidth
の分だけ原点移動しておくことで、 Offsetの計算のときに余計な項を足したり引いたりする必要がなくなります。また、canvas.save
, canvas.restore
というメソッドを使うことにより、 translate で移動した原点を保存したり、復旧させたりできます。
イメージは以下です。
原点を移動させてからなにかを描画したのちに、元々の原点に復旧させたいといったときには、上記のメソッドを使いましょう。
このように原点移動と Offsetの計算を繰り返して、軸・ラベル・グリッドを描画します。
ソースコードの全体は以下のようになります。(クリックすると開きます。)
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
は、以下のようなデータモデルで表現しています。
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
を利用しています。馴染みのない方は以下を参照ください。
処理の流れ
-
Reservation
を1つ渡し、線分の始点と終点の Offset をsince
,until
, および描画範囲のxMin
,xMax
から計算します。 - Offset から線分を描画します。
- データをクリックしたときのクリックイベントを登録します。
ここで、 since
, until
に対応する Offsetの計算方法を説明します。
// 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を書き換えています。
Offsetの計算は非常にシンプルで、描画期間(xMin~xMax)を 1 としたとき、since(またはuntil)の位置が 何割のところにあるかを求めて、 MainRegionの幅をかけてやるだけです。
これで since, untilのOffsetが求められます。
あとは、Offsetを使って線分を描画します。ここでは Reservation
の status
フィールドを参照して、線分の色を予約状況によって変える処理を入れています。また予約者名も線分上に描画したいので、TextPainter
を使って描画します。
// 線分の描画
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を使って積み上げます。実装は以下のようになります。
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
でラップすることで実現できます。
@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の最上部の層のクリックイベントのみが実行され、どこをクリックしても同じ予約情報の詳細に飛ぶことになります。
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
)のクリックなのかどうかを判定させます。
hitAreaはデータの線分よりも若干大きい Path
として計算しています。これは実際には描画されない、論理的な領域です。Pathは引数にOffsetをとってその領域内かどうかを判定する contain()
メソッドを持っているので利用します。
このように実装することで、Stackに積み上げても下層の有効領域は活きるため、データをクリックした際に適切な予約詳細情報の画面に遷移させることができます。
まとめ
以上ですべての構成要素が実装できました。他に特定のデータにだけアニメーションを付けたりと、要望に応じてカスタマイズすることができます。もっと良い実装があるよ!という場合は、コメントで教えていただけると幸いです。