はじめに
元ネタはこちら:
Appleのモバイル版Booksアプリの特徴的なセミモーダルUIを再現しようという記事ですが、今回はこれをFlutterで実装してみたというお話です。
Booksアプリはタイル状に並んだ本をタップすると本の詳細画面がセミモーダルなダイアログとして表示され、左右にスワイプすることで別の本の詳細を見ることもできます。また大きな特徴として詳細画面のスクロールに伴い表示領域が徐々に拡大(縮小)する点が挙げられます。元記事でも言及されていることですが、このUIの良いところは
- スワイプによって前後のコンテンツを切り替えられることがユーザーに一目で伝わる
- スクロールで全画面に移行するため、レイアウトの(水平方向の)スペースが削られない
- 全画面時はスワイプによるページ切り替えが無効になるため、flutter_slidableのような横スワイプアクションと
PageView
のページ切り替えを両立させることができる
といった点にあります。このような動作をFlutter実装する場合、通常はPageView
を使用します。左右のページが少しだけ顔を出していますが、これはPageController.viewportFraction
を例えば0.9などに設定することで実現できます。しかし、PageController.viewportFraction
はfinal
な変数なのでPageView
では動的に表示領域を変化させることは難しいです。
完全なコードは分量の都合上この記事に載せることができませんので、ここでは最も重要な、スクロールに伴い表示領域が変化する部分の実装をのみを紹介します。
成果物
使いやすい形にまとめてパッケージとして公開しました。こんな👇感じのUIを簡単に作成できます。まだβ版なのでドキュメントなど不完全ですが、フィードバックをいただけると幸いです。
アイデア
PageView
での実現は難しいと冒頭で述べましたが、不可能というわけではありません。要はPageController.viewportFraction
がスクロールに合わせて変化してくれれば良いので、
- スクロールを検知して新しい
viewportFraction
を計算 - 新しい
viewportFraction
と一緒に新しいPageController
を作成 - 更新したPageControllerと一緒に
PageView
をrebuild
することで、かなり力技ですが目的の動作を実現できます。ただviewportFraction
が変化するということは各ページの横幅が変化するということなので、そのたびに各ページがrebuildされ、とても重たいUIになってしまいます。実際、この実装では単純なListTile
を並べただけでもかなりもたついた操作感でした。
なるべくrebuildを避けながら表示領域を変化させるにはどうしたら良いでしょうか?そこでTransform
の出番です。Transform
での拡大縮小ならページ全体を毎回rebuildする必要はありませんのでこちらの方がパフォーマンスに利があります。そこで今回は次のような方向性で実装することにしました。
- 各ページの横幅が
PageController.viewportFraction
の値に関わらず常に画面いっぱいに広がるように強制するPageView
を作成(以下、AlwaysFillViewportPageView
) -
viewportFraction
に表示領域の縮小率の最小値(例えば0.9)を設定したPageController
をAlwaysFillViewportPageView
に渡す - 各ページを
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);
}
RenderSliverFillViewport
はRenderSliverFixedExtentBoxAdapter
を継承していますが、各ページのサイズはこのクラスの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
の値がページの横幅になります。itemExtent
はRenderSliverFillViewport
で定義されており、viewportFraction
によって横幅が変わることが分かります。
// RenderSliverFillViewport
@override
double get itemExtent => constraints.viewportMainAxisExtent * viewportFraction;
長々と続きましたが、結局は
final BoxConstraints childConstraints = constraints.asBoxConstraints(
minExtent: itemExtent,
maxExtent: itemExtent,
のitemExtent
をconstraints.viewportMainAxisExtent
に置き換えればよさそうです。
final BoxConstraints childConstraints = constraints.asBoxConstraints(
minExtent: constraints.viewportMainAxisExtent,
maxExtent: constraints.viewportMainAxisExtent,
これにより各ページの横幅はviewportFraction
に関わらず常に画面いっぱいに広がります。RenderSliverFillViewport
のitemExtent
をオーバーライドしてviewportFraction
を無視する方法も考えられますが、itemExtent
はスクロール範囲の決定など他の箇所でも利用されているため、今回はRenderSliverFillViewport
を継承し、performLayout
を変更することにしました。
あとはこの改変版RenderSliverFillViewport
をPageView
に組み込むだけですが、PageView
とRenderSliverFillViewport
の間にプライベートなクラスがたくさん挟まっているので色々コピペする必要があります。上記の変更以外はほぼコピペなのでコード全体を載せることはしませんが、興味のある方は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();
},
),
);
}
[実装2] Transformで拡大縮小アニメーション
スクロールを検知してTransform
でページの大きさを拡大縮小できるように上記コードのRandomColoredListView
を変更します。
まず縮小率(viewportFraction
)ですが、ここでは例としてListView
を初期位置から200pxスクロールする間に縮小率が0.9から1.0に変化するようにします。またページの表示領域の下部が常に画面の下側中央にくるよう、Matrix4.translate
で水平・垂直移動も同時に行います。おおまかな流れは以下のようになります。
-
ScrollController.addListener
でスクロールを検知 -
ValueNotifier<double>
で縮小率を管理 -
ValueListenableBuilder
で縮小率の変化を検知してrebuild - 縮小率に合わせて水平・垂直方向の移動量を計算
-
Transform
とMatrix4.scale
、Matrix4.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.スクロールに合わせていい感じにページの表示領域を拡大縮小する」ができました。
おわり
本記事のコードだけだと完全再現には程遠いですが、見通しが立つ程度のところまでは出来ていると思います。残っている問題としては、ページが拡大した時に左右のページと重なってしまう、拡大とスクロールが同時進行で起こるためにリストの先頭が見切れてしまう、といったことが挙げられますが、分量が多いのでまた機会があれば別記事で紹介したいと思います。興味がある方はGitHub上のコードをご参照ください。