📜

PrimaryScrollControllerとStatefulShellRouteの組み合わせ

2023/11/05に公開

https://github.com/FlutterKaigi/conference-app-2023/pull/246

こちらのPRで対応した内容と、なぜそうしたのかのメモです。

前提

iOSにおいて、status bar領域(左上の時計など)をタップすると、表示しているリストがposition: 0までスクロールされる機能があります。
この機能をFlutterで実現するために、PrimaryScrollControllerを利用することが多いのではないでしょうか。

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

PrimaryScrollControllerCustomScrollViewListViewSingleChildScrollViewを組み合わせるには、2つの方法があります。

  1. primaryプロパティをtrueにする
  2. PrimaryScrollController.of(context)で取得したcontrollerをセットする

primaryプロパティの場合、ScrollViewクラスの内部で、PrimaryScrollController.of(context)で取得したcontrollerをセットする動きになっています。

https://github.com/flutter/flutter/blob/3.13.9/packages/flutter/lib/src/widgets/scroll_view.dart#L456-L461

問題

https://github.com/flutter/flutter/issues/85603#issuecomment-876798161

議論に上がっている話題としては、Navigatorがネストされているケースにおいて、意図したPrimaryScrollControllerにアクセスできない問題が発生します。PrimaryScrollControllerModalRouteごとに生成されることが原因です。(筆者の理解としては、画面がpushされた時に、新たに表示された画面用のPrimaryScrollControllerが生成されることを意図していたのかなと。)

また、FlutterKaigi 2023アプリでは、StatefulShellRouteを利用しています。

https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html

この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等に関連づけられているPrimaryScrollControllerScaffoldで利用されるようになれば良いはずです。
この仕組みを、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が表示された時」に発火するようにしています。

https://pub.dev/packages/visibility_detector

数カ所で利用するので、次のような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のメリットを活かせるのではないでしょうか。

おわりに

PrimaryScrollControllerStatefulShellRouteの組み合わせにおいて、PrimaryScrollControllerが期待通りに動作しない問題について、2つの対応案を紹介しました。
FlutterKaigi 2023アプリでは、色々と実験的に試している部分が多いので、このような問題が発生することがあります。そのため、このような問題が発生した場合には、ぜひIssueやPRを作成していただけると嬉しいです。

2023年11月10日をおたのしみに!

GitHubで編集を提案

Discussion