PrimaryScrollControllerとStatefulShellRouteの組み合わせ
こちらのPRで対応した内容と、なぜそうしたのかのメモです。
前提
iOSにおいて、status bar領域(左上の時計など)をタップすると、表示しているリストがposition: 0
までスクロールされる機能があります。
この機能をFlutterで実現するために、PrimaryScrollController
を利用することが多いのではないでしょうか。
PrimaryScrollController
とCustomScrollView
やListView
、SingleChildScrollView
を組み合わせるには、2つの方法があります。
-
primary
プロパティをtrue
にする -
PrimaryScrollController.of(context)
で取得したcontrollerをセットする
primary
プロパティの場合、ScrollView
クラスの内部で、PrimaryScrollController.of(context)
で取得したcontrollerをセットする動きになっています。
問題
議論に上がっている話題としては、Navigatorがネストされているケースにおいて、意図したPrimaryScrollController
にアクセスできない問題が発生します。PrimaryScrollController
がModalRoute
ごとに生成されることが原因です。(筆者の理解としては、画面がpushされた時に、新たに表示された画面用のPrimaryScrollController
が生成されることを意図していたのかなと。)
また、FlutterKaigi 2023アプリでは、StatefulShellRoute
を利用しています。
このStatefulShellRoute
は、StatefulShellRoute
のsub routesでNavigatorを切り替える仕組みです。結果として、画面全体(Scaffold
を管理しているNavigator)とListView
等(sub routeとして管理されたNavigator)の間で、PrimaryScrollController
の問題が発生することになります。
対応
StatefulShellRouter
で対応する方法を考えると、いくつかの対応方法があります。筆者としては、ここでは「FlutterのWidget tree上の問題なので、FlutterのWidgetを使って解決する」方向で進めたいです。このため、大きく2つの案が思い付きます。InheritedWidget
を利用する案(parent to child)と、NotificationListener
を利用する案(child to parent)です。
InheritedWidget
を利用する案
Scaffold
を管理しているNavigatorがstatus barと連携しているので、そのPrimaryScrollController
をbody部で利用できるようにすればよさそう。
ということで、次のようなInheritedWidget
を作成します。
class RootPrimaryScrollController extends InheritedWidget {
const RootPrimaryScrollController({
required this.controller,
}):
final ScrollController controller;
static RootPrimaryScrollController? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<RootPrimaryScrollController>();
}
static RootPrimaryScrollController of(BuildContext context) {
final RootPrimaryScrollController? result = maybeOf(context);
assert(result != null, 'No RootPrimaryScrollController found in context');
return result!;
}
bool updateShouldNotify(RootPrimaryScrollController oldWidget) => controller != oldWidget.controller;
}
続いて、rootで次のようにPrimaryScrollController
を配布します。
return RootPrimaryScrollController(
controller: PrimaryScrollController.of(context),
child: Scaffold(),
);
最後に、ListView
などを実装しているクラスで RootPrimaryScrollController.of(context)!.controller;
を呼び出せば、root画面で利用されているPrimaryScrollController
が利用されます。
NotificationListener
を利用する案
表示中のListView
等に関連づけられているPrimaryScrollController
がScaffold
で利用されるようになれば良いはずです。
この仕組みを、NotificationListener
を利用して実現します。
まず、NotificationListener
を利用するために、ScrollControllerNotification
を定義します。
class ScrollControllerNotification extends Notification {
const ScrollControllerNotification({
required this.controller,
});
final ScrollController controller;
}
続いて、rootの画面をStatefulWidget
に変更し、次のような実装にします。
class _RootScreenState extends State<RootScreen> {
ScrollController? _primaryScrollController;
Widget build(BuildContext context) {
return NotificationListener<ScrollControllerNotification>(
onNotification: (notification) {
if (_primaryScrollController != notification.controller) {
setState(() {
_primaryScrollController = notification.controller;
});
}
return true;
},
child: PrimaryScrollController(
controller: _primaryScrollController ?? PrimaryScrollController.of(context),
最後に、ListView
などを実装しているクラスで、ScrollControllerNotification.dispatch
を行います。
PRではbuild
メソッドでは意図したタイミングで発火しないことがわかったので、visibility_detector
を利用し「Widgetが表示された時」に発火するようにしています。
数カ所で利用するので、次のようなWrapper Widgetを作成します。
class VisibleDetectScrollControllerNotifier extends StatelessWidget {
const VisibleDetectScrollControllerNotifier({
super.key,
required this.visibleDetectorKey,
required this.child,
});
final Key visibleDetectorKey;
final Widget child;
Widget build(BuildContext context) {
return VisibilityDetector(
key: visibleDetectorKey,
onVisibilityChanged: (info) {
if (info.visibleFraction == 1) {
ScrollControllerNotification(
controller: PrimaryScrollController.of(context),
).dispatch(context);
}
},
child: child,
);
}
}
最後に、ListView
などを実装しているクラスで、VisibleDetectScrollControllerNotifier
でラップ + primary
プロパティをtrue
にすることで、PrimaryScrollController
が利用されるようになります。
class SubRoutePage extends StatelessWidget {
const SubRoutePage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return VisibleDetectScrollControllerNotifier(
visibleDetectorKey: const Key('SubRoutePage'),
child: ListView(
primary: true,
対応案の比較
2つの案は、手元で試す限りでは、どちらも期待通りの動きをしているように見えます。
唯一違うのは、単一のPrimaryScrollController
がすべてのListView
で利用されるか、逆に、ListView
ごとにPrimaryScrollController
が利用されるか、という点です。
StatefulShellRoute
を利用することで得られるメリットとして、各sub routeの状態を保持できる点があります。
このため、可能であれば、ListView
ごとに「スクロール位置」は保持されている方が良いと思われます。この点において、NotificationListener
を利用する案の方が、StatefulShellRoute
のメリットを活かせるのではないでしょうか。
おわりに
PrimaryScrollController
とStatefulShellRoute
の組み合わせにおいて、PrimaryScrollController
が期待通りに動作しない問題について、2つの対応案を紹介しました。
FlutterKaigi 2023アプリでは、色々と実験的に試している部分が多いので、このような問題が発生することがあります。そのため、このような問題が発生した場合には、ぜひIssueやPRを作成していただけると嬉しいです。
2023年11月10日をおたのしみに!
Discussion