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?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-04-12

はじめに

元ネタはこちら:

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

RPReplay_Final1681306231_AdobeExpress.gif

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

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

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

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

成果物

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

demo.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)を設定したPageControllerAlwaysFillViewportPageViewに渡す
  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では前後のページを端に表示することができません。

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

[実装1] AlwaysFillViewportPageView

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

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

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

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

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

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

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

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

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

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

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

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

itemExtentconstraints.viewportMainAxisExtentに置き換えればよさそうです。

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

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

あとはこの改変版RenderSliverFillViewportPageViewに組み込むだけですが、PageViewRenderSliverFillViewportの間にプライベートなクラスがたくさん挟まっているので色々コピペする必要があります。上記の変更以外はほぼコピペなのでコード全体を載せることはしませんが、興味のある方はGitHubのソースコードをご参照ください。

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

  @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

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

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

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

  1. ScrollController.addListenerでスクロールを検知
  2. ValueNotifier<double>で縮小率を管理
  3. ValueListenableBuilderで縮小率の変化を検知してrebuild
  4. 縮小率に合わせて水平・垂直方向の移動量を計算
  5. TransformMatrix4.scaleMatrix4.translateで縮小&移動
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を指定します。

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

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

mock2.gif

おわり

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

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?