Flutter で Bar Chart 📊 を自作しよう！

:::note info
2024/9/14 に石川県で開催された [FlutterKaigi mini #2](https://flutterkaigi.connpass.com/event/325469/) で LT した資料です。
:::

## 💪 モチベーション
* 🔋 愛車（EV）の日々の充電データをグラフ化したい
* 📊 デザインやインタラクションにもこだわりたい
* 🤔 [fl_chart](https://pub.dev/packages/fl_chart) 等のパッケージは UI の細かい調整が難しい印象
* 😌 Flutter で凝った UI を実装してみたいな〜

👉 📊 Bar Chart を（業務で）自作したので LT でサクっと紹介します！

:::note warn
iOS「ヘルスケア」アプリの Bar Chart の UI に近いものを作ったため、以降は「ヘルスケア」アプリの動作のスクリーン動画を例に、その実装例を紹介していきます。
:::

---
## 📊 1. Bar Chart のベース部分 & スクロールによる表示内容の変化

![2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/a5d17a9a-e378-5e87-c181-17293c1cffc6.gif)

---

## 📊 1. Bar Chart ベース部分
* 水平方向の [ListView](https://api.flutter.dev/flutter/widgets/ListView-class.html) で、アイテムが項目軸(X)の各データ要素を表現
* 各データ要素は基本的な Widget の組み合わせで
* `controller`に [PageController](https://api.flutter.dev/flutter/widgets/PageController-class.html)、`physics`に [PageScrollPhysics](https://api.flutter.dev/flutter/widgets/PageScrollPhysics-class.html) を使うことでスクロール終了時にアイテム（＝ページ）が綺麗にフィット
* スクロールでのページ変化時に [HapticFeedback](https://api.flutter.dev/flutter/services/HapticFeedback-class.html) つけるといい感じ

```dart
ListView.builder(
  controller: usePageController(...),
  physics: const PageScrollPhysics(),
  scrollDirection: Axis.horizontal,
  ...
);
```

---
## 📊 2. スクロールによる表示内容の変化
* [NotificationListener](https://api.flutter.dev/flutter/widgets/NotificationListener-class.html)と[ScrollNotification](https://api.flutter.dev/flutter/widgets/ScrollNotification-class.html)でスクロール状態を検知
* 表示範囲内のデータによって以下の値を変化させる
  * データの期間 → ラベルと合計値
  * データの最大値 → 値軸(Y)の範囲と目盛り 

```dart
NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    // 表示範囲内のデータを求めてラベルや目盛りを変更
  },
  child: ListView.builder(
    ...
  )
);
```

---
## 📊 3. バーのアニメーション

![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/5157756c-3c38-28fb-1c91-c0b5220eae03.gif)

---
## 📊 3. バーのアニメーション
* 値軸(Y)の範囲が変わるとデータ要素の高さも変わる
* [AnimationController](https://api.flutter.dev/flutter/animation/AnimationController-class.html) で高さの変化をアニメーションさせることで、範囲が変わったことをわかりやすくなる

```dart
final controller = useAnimationController(...);
final value = useAnimation(CurvedAnimation(parent: controller, curve: ...));
...
SizedBox(
  height: barHeight * value,
  child: ...,
);
```

---
## 📊 4. タッチ中の要素にフォーカス

![4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16508/80aaa719-abb8-1f52-834e-2cff102aa9c2.gif)

---
## 📊 4. タッチ中の要素にフォーカス
* データ要素をタッチ中はその要素だけのデータを値をラベルに表示
* [GestureDetector](https://api.flutter.dev/flutter/widgets/GestureDetector-class.html) でタッチ中の要素を判定

```dart
ListView.builder(
  ...
  itemBuilder: (context, index) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTapDown: (_) => onSelectItem(index),
      onTapUp: (_) => onDeselectItem(),
      onLongPressStart: (_) => onDeselectItem(index),
      onLongPressEnd: (_) => onDeselectItem(),
      onLongPressUp: () => onDeselectItem(),
      onLongPressMoveUpdate: (details) {
        final touchIndex = /* details.globalPositionのある要素インデックス */
        onSelectItem(touchIndex);
      },
      child: ...
   );
  }
)
```

---
## 📊 SNS等にシェア（オマケ）
* Bar Chart の部分を画像にして SNS 等にシェアする
* Bar Chart カードの Widget を [RepaintBoundary](https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html) で囲い、指定したキーを通して得られる [RenderRepaintBoundary](https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary-class.html) から [toImage()](https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImage.html) で画像データを取得
* [share_plus](https://pub.dev/packages/share_plus)パッケージを使ってシェア

```dart
RepaintBoundary( 
  key: key, // GlobalKey
  child: BarChartCard(...),
);
```
```dart
final boundary =
        key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage();
```