Apple製BooksアプリのセミモーダルUIを再現する [ 勝手にFlutter編 ]

## はじめに

元ネタはこちら：

https://qiita.com/iincho/items/c8e398d28273abb08862

Appleのモバイル版Booksアプリの特徴的なセミモーダルUIを再現しようという記事ですが、今回はこれをFlutterで実装してみたというお話です。

![RPReplay_Final1681306231_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/0a1adb8a-e97f-e655-c3f4-47d79ae226d2.gif)

Booksアプリはタイル状に並んだ本をタップすると本の詳細画面がセミモーダルなダイアログとして表示され、左右にスワイプすることで別の本の詳細を見ることもできます。また大きな特徴として詳細画面のスクロールに伴い表示領域が徐々に拡大（縮小）する点が挙げられます。元記事でも言及されていることですが、このUIの良いところは

- スワイプによって前後のコンテンツを切り替えられることがユーザーに一目で伝わる
- スクロールで全画面に移行するため、レイアウトの（水平方向の）スペースが削られない
- 全画面時はスワイプによるページ切り替えが無効になるため、[flutter_slidable](https://pub.dev/packages/flutter_slidable)のような横スワイプアクションと`PageView`のページ切り替えを両立させることができる

といった点にあります。このような動作をFlutter実装する場合、通常は`PageView`を使用します。左右のページが少しだけ顔を出していますが、これは`PageController.viewportFraction`を例えば0.9などに設定することで実現できます。しかし、`PageController.viewportFraction`は`final`な変数なので`PageView`では動的に表示領域を変化させることは難しいです。

完全なコードは分量の都合上この記事に載せることができませんので、ここでは最も重要な、スクロールに伴い表示領域が変化する部分の実装をのみを紹介します。



## 成果物

https://github.com/fujidaiti/exprollable_page_view

使いやすい形にまとめてパッケージとして公開しました。こんな👇感じのUIを簡単に作成できます。まだβ版なのでドキュメントなど不完全ですが、フィードバックをいただけると幸いです。


![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/db31d628-76d3-4241-ae51-ee97ce6995ca.gif)


## アイデア

`PageView`での実現は難しいと冒頭で述べましたが、不可能というわけではありません。要は`PageController.viewportFraction`がスクロールに合わせて変化してくれれば良いので、

1. スクロールを検知して新しい`viewportFraction`を計算
2. 新しい`viewportFraction`と一緒に新しい`PageController`を作成
3. 更新したPageControllerと一緒に`PageView`をrebuild

することで、かなり力技ですが目的の動作を実現できます。ただ`viewportFraction`が変化するということは各ページの横幅が変化するということなので、そのたびに各ページがrebuildされ、とても重たいUIになってしまいます。実際、この実装では単純な`ListTile`を並べただけでもかなりもたついた操作感でした。

なるべくrebuildを避けながら表示領域を変化させるにはどうしたら良いでしょうか？そこで`Transform`の出番です。`Transform`での拡大縮小ならページ全体を毎回rebuildする必要はありませんのでこちらの方がパフォーマンスに利があります。そこで今回は次のような方向性で実装することにしました。

1. 各ページの横幅が`PageController.viewportFraction`の値に関わらず常に画面いっぱいに広がるように強制する`PageView`を作成（以下、`AlwaysFillViewportPageView`）
2. `viewportFraction`に表示領域の縮小率の最小値（例えば0.9）を設定した`PageController`を`AlwaysFillViewportPageView`に渡す
3. 各ページを`Transform`で囲い、スクロールに連動して縮小率を変化させる（`PageController.viewportFraction` ~ 1.0の間）

`viewportFraction`は各ページの横幅だけでなく、`PageView`のスクロール範囲やスナップ位置を計算するのにも使用されるため、これを縮小率の最小値に合わせる必要があります。ただ各ページの（見かけ上の）横幅は`Transform`に変化させるため、`viewportFraction`の値に関わらず各ページの横幅を常に画面いっぱいに広がるよう強制する`PageView`を作る必要があります。

単に`viewportFraction=0.9`などとして`Transform`でページを拡大（例えば全画面時は拡大率1.0/0.9=1.111...）する方法も考えられますが、その場合、全画面時にフォントやWidgetのサイズが意図したデザインよりも若干大きくなってしまうため少々扱いづらいWidgetになってしまいます。そのため`PageView`の作った小さいページを拡大するのではなく、`AlwaysFillViewportPageView`の作った画面いっぱいに広がったページを縮小しておき、全画面時には元のサイズに戻すという方法を取りました。それならば`viewportFraction=1.0`にした通常の`PageView`で十分では？と思う方もいるかもしれませんが、`PageView`含め`Sliver`系のWidgetは画面に映らないWidgetをWidgetツリーから削除するという実装になっています。そのため`viewportFraction=1.0`では前後のページを端に表示することができません。

細々とした話が続きましたが、まとめると１．`viewportFraction`を無視して常に全画面表示の特殊な`PageView`を作る、２．スクロールに合わせて`Transform`でいい感じにページをスケールする、という手順です。それでは実装に移ります。


## [実装1] AlwaysFillViewportPageView

「１．`viewportFraction`を無視して常に全画面表示の特殊な`PageView`」を作ります。そのためには、`PageView.itemBuilder`で生成される各ページがどのようにレイアウトされるのかを知る必要がありそうです。そこで`PageView`のソースコードを追ってみます。

```dart
// flutter/packages/flutter/lib/src/widgets/page_view.dart
// _PageViewState.build()
child: Scrollable(
          return Viewport(
            slivers: <Widget>[
              SliverFillViewport(
								...
```

長いのでかなり省略していますが、`PageView`のbuild関数は上記のようになっています。どうやらページの管理や描画をしているのは`SliverFillViewport`のようです。

```dart
// flutter/packages/flutter/lib/src/widgets/sliver_fill.dart
// SliverFillViewport.build()
return _SliverFractionalPadding(
      sliver: _SliverFillViewportRenderObjectWidget(
				...
```

`SliverFillViewport`は`_SliverFillViewportRenderObjectWidget`をラップしていますが、これに紐づいた`RenderSliverFillViewport`というRenderObjectが実際にページの管理や描画を担当しています。

```dart
// flutter/packages/flutter/lib/src/widgets/sliver_fill.dart
// _SliverFillViewportRenderObjectWidget
@override
RenderSliverFillViewport createRenderObject(BuildContext context) {
  final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
  return RenderSliverFillViewport(childManager: element, viewportFraction: viewportFraction);
}
```

`RenderSliverFillViewport`は`RenderSliverFixedExtentBoxAdapter`を継承していますが、各ページのサイズはこのクラスの`performLayout`によって決定されます。

```dart
// flutter/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart
// RenderSliverFixedExtentBoxAdapter
@override
void performLayout() {
  ...
  final BoxConstraints childConstraints = constraints.asBoxConstraints(
    minExtent: itemExtent,
    maxExtent: itemExtent,
  );
  ...
```

これも分量が多いので省略しましたが、今回重要な部分はこの３行だけです。`childConstraints`が各ページの`layout`関数に渡される`BoxConstraints`であり、ここで指定した`itemExtent`の値がページの横幅になります。`itemExtent`は`RenderSliverFillViewport`で定義されており、`viewportFraction`によって横幅が変わることが分かります。

```dart
// RenderSliverFillViewport
@override
  double get itemExtent => constraints.viewportMainAxisExtent * viewportFraction;
```



長々と続きましたが、結局は

```dart
  final BoxConstraints childConstraints = constraints.asBoxConstraints(
    minExtent: itemExtent,
    maxExtent: itemExtent,
```

の`itemExtent`を`constraints.viewportMainAxisExtent`に置き換えればよさそうです。

```dart
  final BoxConstraints childConstraints = constraints.asBoxConstraints(
    minExtent: constraints.viewportMainAxisExtent,
    maxExtent: constraints.viewportMainAxisExtent,
```

これにより各ページの横幅は`viewportFraction`に関わらず常に画面いっぱいに広がります。`RenderSliverFillViewport`の`itemExtent`をオーバーライドして`viewportFraction`を無視する方法も考えられますが、`itemExtent`はスクロール範囲の決定など他の箇所でも利用されているため、今回は`RenderSliverFillViewport`を継承し、`performLayout`を変更することにしました。

あとはこの改変版`RenderSliverFillViewport`を`PageView`に組み込むだけですが、`PageView`と`RenderSliverFillViewport`の間にプライベートなクラスがたくさん挟まっているので色々コピペする必要があります。上記の変更以外はほぼコピペなのでコード全体を載せることはしませんが、興味のある方は[GitHubのソースコード](https://github.com/fujidaiti/exprollable_page_view/blob/master/package/lib/src/internal/paging.dart)をご参照ください。

こうしてできたのが`AlwaysFillViewportPageView`です。使い方や見た目は`PageView`と同じですが、`viewportFraction`を0.9としたにも関わらずページの横幅が画面いっぱいに広がっているなことが、隣り合ったページの重なりからみて取れます（分かりやすいように背景色を半透明にしてあります）。

```dart
  @override
  void initState() {
    super.initState();
    pageController = PageController(viewportFraction: 0.9);
  }  

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // コレ↓
      body: AlwaysFillViewportPageView.builder(
        controller: pageController,
        itemBuilder: (context, page) {
          // 分かりやすいように半透明な背景色をランダムにつけたListView
          return RandomColoredListView();
        },
      ),
    );
  }
```

![moc.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/24f3c88e-12da-ada1-6b1a-8de0fe8ab73e.gif)


## [実装2] Transformで拡大縮小アニメーション

スクロールを検知して`Transform`でページの大きさを拡大縮小できるように上記コードの`RandomColoredListView`を変更します。

まず縮小率（`viewportFraction`）ですが、ここでは例として`ListView`を初期位置から200pxスクロールする間に縮小率が0.9から1.0に変化するようにします。またページの表示領域の下部が常に画面の下側中央にくるよう、`Matrix4.translate`で水平・垂直移動も同時に行います。おおまかな流れは以下のようになります。

1. `ScrollController.addListener`でスクロールを検知
2. `ValueNotifier<double>`で縮小率を管理
3. `ValueListenableBuilder`で縮小率の変化を検知してrebuild
4. 縮小率に合わせて水平・垂直方向の移動量を計算
5. `Transform`と`Matrix4.scale`、`Matrix4.translate`で縮小＆移動

```dart
class RandomColoredListView extends StatefulWidget {
  const RandomColoredListView({
    super.key,
    required this.minViewportFraction,
  });

  final double minViewportFraction;

  @override
  State<RandomColoredListView> createState() =>
      _RandomColoredListViewState();
}

class _RandomColoredListViewState extends State<RandomColoredListView> {
  final color = Color.fromARGB(
    180,
    Random().nextInt(155) + 50,
    Random().nextInt(155) + 50,
    Random().nextInt(155) + 50,
  );

  late final ValueNotifier<double> viewportFraction;
  late final ScrollController scrollController;
  double? viewportHeight;
  double? viewportWidth;

  @override
  void initState() {
    super.initState();
    scrollController = ScrollController()..addListener(invalidateState);
    viewportFraction = ValueNotifier(widget.minViewportFraction);
  }

  @override
  void dispose() {
    super.dispose();
    viewportFraction.dispose();
    scrollController.dispose();
  }

  void invalidateState() {
    viewportFraction.value = computeViewportFraction();
  }

  double computeViewportFraction() {
    const thresholdScrollOffset = 200.0;
    final scrollOffset = scrollController.offset;
    final t = (scrollOffset / thresholdScrollOffset).clamp(0.0, 1.0);
    return t + (1.0 - t) * widget.minViewportFraction;
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (_, constraints) {
        viewportHeight = constraints.maxHeight;
        viewportWidth = constraints.maxWidth;

        final body = Container(
          color: color,
          child: ListView.builder(
            controller: scrollController,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('Item#$index'),
              );
            },
          ),
        );

        return ValueListenableBuilder(
          valueListenable: viewportFraction,
          builder: (_, fraction, __) {
            final diff = (fraction - widget.minViewportFraction);
            final dy = (1.0 - fraction) * viewportHeight!;
            final dx = diff * viewportWidth! / -2.0;
            return Transform(
              transform: Matrix4.identity()
                ..translate(dx, dy)
                ..scale(fraction),
              child: body,
            );
          },
        );
      },
    );
  }
}
```

また`RandomColoredListView`のコンストラクタで縮小率の最小値（`minViewportFraction`）を渡すようにしていますが、これには`PageController.viewportFraction`を指定します。

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AlwaysFillViewportPageView.builder(
        controller: pageController,
        itemBuilder: (context, page) {
          return RandomColoredListView(
            // コレ↓
            minViewportFraction: pageController.viewportFraction,
          );
        },
      ),
    );
  }
```



これで「２．スクロールに合わせていい感じにページの表示領域を拡大縮小する」ができました。


![mock2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112065/cc4ce579-6064-7e13-615b-ba2ed7df1bed.gif)


## おわり

本記事のコードだけだと完全再現には程遠いですが、見通しが立つ程度のところまでは出来ていると思います。残っている問題としては、ページが拡大した時に左右のページと重なってしまう、拡大とスクロールが同時進行で起こるためにリストの先頭が見切れてしまう、といったことが挙げられますが、分量が多いのでまた機会があれば別記事で紹介したいと思います。興味がある方は[GitHub上のコード](https://github.com/fujidaiti/exprollable_page_view)をご参照ください。