PageViewの高さを表示内容に動的に合わせる方法

# はじめに

Flutter開発でページの内容に応じて`PageView`のサイズを動的に変更する方法がないか調べたので記事にします。
例えば、`Image.network`を使って画像を表示し、表示した後画像に合わせてPageViewの大きさを変更するようなケースです。

# ソースコード

```dart
class FlexiblePageView extends StatefulWidget {
  final List<Widget> children;
  final double initHeight;
  final Function(double)? onHeightChanged;

  const FlexiblePageView({
    Key? key,
    required this.children,
    this.initHeight = 100,
    this.onHeightChanged,
  }) : super(key: key);

  @override
  State<FlexiblePageView> createState() => _FlexiblePageViewState();
}

class _FlexiblePageViewState extends State<FlexiblePageView> {
  final GlobalKey viewKey = GlobalKey();
  late PageController _pageController;

  /// PageViewの高さ
  late double height;

  /// 各ページの高さのリスト
  ///
  /// ページを切り替えた時に高さを変更するように、各ぺーじの高さを保持しておく
  List<double> pageHeightList = [];

  @override
  void initState() {
    _pageController = PageController();
    height = widget.initHeight;
    super.initState();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.yellow,
      height: height,
      child: PageView.builder(
        controller: _pageController,
        itemCount: widget.children.length,
        onPageChanged: (value) {
          if (pageHeightList.length <= value) {
            return;
          }

          // 保存しているページの高さがあれば設定する
          setState(() {
            height = pageHeightList[value];
          });
        },
        itemBuilder: (context, index) {
          return OverflowBox(
            maxHeight: double.infinity,
            child: SizeReportingWidget(
              child: widget.children[index],
              onSizeChange: (size) {
                // 変更通知された高さと初期値の高さが同じ場合、
                // pageHeightListに高さを追加できないケースがあるので、
                // height == widget.initHeight場合は、追加する
                if (height == widget.initHeight || size.height != height) {
                  setState(
                    () {
                      height = size.height;
                      if (pageHeightList.length <= index) {
                        pageHeightList.add(size.height);
                      } else {
                        pageHeightList[index] = size.height;
                      }
                    },
                  );
                }
              },
            ),
          );
        },
      ),
    );
  }
}

class SizeReportingWidget extends StatefulWidget {
  final Widget child;
  final ValueChanged<Size> onSizeChange;

  const SizeReportingWidget({
    Key? key,
    required this.child,
    required this.onSizeChange,
  }) : super(key: key);

  @override
  State<SizeReportingWidget> createState() => _SizeReportingWidgetState();
}

class _SizeReportingWidgetState extends State<SizeReportingWidget> {
  final _widgetKey = GlobalKey();
  Size? _oldSize;

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
    return NotificationListener<SizeChangedLayoutNotification>(
      onNotification: (_) {
        WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
        return true;
      },
      child: SizeChangedLayoutNotifier(
        child: Container(
          key: _widgetKey,
          child: widget.child,
        ),
      ),
    );
  }

  void _notifySize() {
    final context = _widgetKey.currentContext;
    if (context == null) return;
    final size = context.size;
    if (_oldSize != size) {
      _oldSize = size;
      widget.onSizeChange(size!);
    }
  }
}
```

### 使う側

```dart
FlexiblePageView(
  children : [
    const SizedBox(
      height: 100,
      child: Center(
        child: Text(
          "height: 100",
          style: TextStyle(fontSize: 20),
        ),
      ),
    ),
    const SizedBox(
      height: 200,
      child: Center(
        child: Text(
          "height: 200",
          style: TextStyle(fontSize: 20),
        ),
      ),
    ),
    const SizedBox(
      height: 300,
      child: Center(
        child: Text(
          "height: 300",
          style: TextStyle(fontSize: 20),
        ),
      ),
    )
  ],
),
```

# 動的にサイズを変更する仕組み
表示しているWidgetに合わせて、動的にPageViewの大きさを変更するには、以下の手順が必要です。
- PageView.builderのitemBuilder内でWidgetの描画完了後、高さを取得する
- 取得した高さでPageViewの大きさを変更する

取得した高さでPageViewの大きさを変更するのは、StatefulWidgetのsetStateで変更できますが、
描画完了後高さを取得するには、子Widget自身から描画完了後に高さを通知して、PageViewを使用しているWidgetで取得する必要があります。


## Widgetの構成
今回の記事では、以下のようなWidgetの構成にして、高さを取得する部分を実現しています。

```mermaid
graph TD;
    A[FlexiblePageView] --> B[SizeReportingWidget]
    B --> C[SizeReportingWidget];
    C --> D[NotificationListener];
    D --> E[SizeChangedLayoutNotifier];
```

- FlexiblePageView
PageViewを使用している**自作のWidget**です。
- SizeReportingWidget
使いやすいようにラッピングした**自作のWidget**で、`NotificationListene`、`SizeChangedLayoutNotifier`を使い、親のWidgetにサイズを通知するようにしています。
- NotificationListener
子Widgetの状態変化のイベントを購読して取得することができる**Flutter標準のWidget**です。詳細は後述。
- SizeChangedLayoutNotifier
子Widgetのサイズが変化した時に通知を発行する**Flutter標準のWidget**です。詳細は後述。

## シーケンス図

```mermaid
sequenceDiagram
    participant A as FlexiblePageView
    participant B as SizeReportingWidget
    participant C as NotificationListener
    participant D as SizeChangedLayoutNotifier
    A->>B: PageViewで描画
    B->>C: 
    C->>D: 
    D->>D: サイズ変更
    D->>C: イベントを通知
    C->>B: イベントを通知
    B->>A: イベントを通知
    A->>A: PageViewのサイズを変更
```

## NotificationListener

NotificationListenerとは、子Widgetの状態変化のイベントを購読して取得することができるウィジェットです。
今回の記事では、`SizeChangedLayoutNotification`を指定し、大きさの変化を購読しています。

下記の記事がわかりやすかったです。

https://qiita.com/youmeee/items/6ca30bf66646b1639076

公式

https://api.flutter.dev/flutter/widgets/NotificationListener-class.html

## SizeChangedLayoutNotification
`SizeChangedLayoutNotification`は、子Widgetのサイズが変更されたら通知を発行するWidgetです。

`NotificationListener`で通知を購読し、`SizeChangedLayoutNotification`で通知を発行するという仕組みとなります。

https://api.flutter.dev/flutter/widgets/SizeChangedLayoutNotifier-class.html