Flutter NestedScrollViewの内側のスクロール量を取得する

# はじめに
Flutterでアプリ開発をしていて、NestedScrollViewの内側のスクロール量を取得する際に少々苦労したので、今回はそのことについてご紹介できればと思います。結論だけ知りたい方は解決策に飛んでいただければと思います。
# NestedScrollViewとは？
複数のスクロール可能なビューを入れ子にすることができ、スクロールをリンクさせることができるウィジェットです。
# 前提条件

今回開発しているアプリの構成は以下のような感じです。DefaultTabControllerの中にNestedScrollViewを埋め込んでいます。ページ全体(緑枠の部分)がスクロール可能なビューとなっており、TabView(紫枠の部分)にまたスクロール要素が埋め込まれているような構成になっています。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1555703/b92733e0-0d04-3ccb-305d-64a9b6894e62.png">

TabViewをスクロールすると連動してページ全体がスクロールされます(下図)。この時、HeaderSectionは画面上部に隠れ、TabBarはAppBarに引っ付くようになっています。また、画面下部にはNestedScrollViewのスクロール量を表示しています。

![Simulator Screen Recording - iPhone 15 - 2024-12-08 at 18.48.51.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1555703/f0c7766a-ba5c-3341-45a4-3835dcf6c1c9.gif)


こちらのサンプルコードを以下に示します。(前後省略しています)
```dart
DefaultTabController(
  length: tabs.length,
  child: Stack(
    children: [
      NestedScrollView(
        controller: scrollController,
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            _headerSection(),
            _tabSection(),
          ];
        },
        body: TabBarView(
          children: tabs.map((e) {
            return ListView.builder(
              itemCount: 50,
              itemBuilder: (context, index) {
                return Container(
                  padding: const EdgeInsets.only(left: 16, top: 12, bottom: 12),
                  color: index % 2 == 0 ? Colors.white12 : Colors.white,
                  child: Text(
                    'index : $index',
                    style: const TextStyle(
                      fontSize: 16,
                    ),
                ),
              );
            });
          }).toList(),
        )
      ),
      …省略
     ],
   ),
 )
```
# 問題点
先ほどのGIFより、HeaderSectionが隠れたあとはスクロール量が変動していないことが分かります。サンプルコードではNestedScrollViewに対してScrollControllerを指定してスクロール量を取得していますが、この方法ではページ全体のスクロール量しか取ることができません。

一方で、TabViewそれぞれをStatefulWidgetにし、それぞれでScrollControllerを設定すれば内側のスクロール量が取れるのではないかと思いつくかもしれません。この場合内側のスクロール量を取得することはできます。一方で、外側のスクロール要素とのリンクが切れてしまい、内側をスクロールしても外側のスクロールとは連動しなくなります(下図)。

![Simulator Screen Recording - iPhone 15 - 2024-12-08 at 21.33.37.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1555703/b10bb885-5bb5-0eea-8aa7-486657f7060c.gif)


# 解決策
内側のスクロール要素に対してNotificationListenerをセットすることで解決できます。
以下にサンプルコードを示します。
```dart
DefaultTabController(
 length: tabs.length,
 child: Stack(
   children: [
     NestedScrollView(
       controller: scrollController,
       headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
         return <Widget>[
           _headerSection(),
           _tabSection(),
         ];
       },
       body: TabBarView(
         children: tabs.map((e) {
           return NotificationListener<ScrollNotification>(
             child: ListView.builder(
               itemCount: 50,
               itemBuilder: (context, index) {
                 return Container(
                   padding: const EdgeInsets.only(left: 16, top: 12, bottom: 12),
                   color: index % 2 == 0 ? Colors.white12 : Colors.white,
                   child: Text(
                     'index : $index',
                     style: const TextStyle(
                       fontSize: 16,
                     ),
                   ),
                 );
               }
             ),
             onNotification: (scrollInfo) {
               setState(() {
                 scrollPosition = scrollInfo.metrics.pixels;
               });
               return false;
             },
           );
         }).toList(),
       )
     ),
     Positioned(
       bottom: 60,
       right: 0,
       left: 0,
       child: Row(
         mainAxisSize: MainAxisSize.min,
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
           Container(
             width: 80,
             height: 40,
             decoration: BoxDecoration(
               borderRadius: BorderRadius.circular(20),
               color: Colors.blueAccent,
             ),
             child: Center(
               child: Text(
                 '${scrollPosition.ceil()}',
                 style: const TextStyle(
                   color: Colors.white,
                   fontSize: 18,
                 ),
               ),
             ),
           ),
         ],
       )
     )
   ],
 ),
)
```


無事スクロールが連動したまま、内側のスクロール量を取得することができました！

![Simulator Screen Recording - iPhone 15 - 2024-12-08 at 21.39.39.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1555703/32415c5b-bd7c-abe9-3f97-4fbe14a08e93.gif)


全体のコードは以下の通りです
```dart
import 'package:flutter/material.dart';


void main() {
 runApp(const MaterialApp(home: MyHomePage(),));
}


class MyHomePage extends StatefulWidget {
 const MyHomePage({super.key});


 @override
 State<StatefulWidget> createState() => MyHomePageState();
}


class MyHomePageState extends State<MyHomePage> {


 late final ScrollController scrollController;


 final tabs = ['Tab1', 'Tab2', 'Tab3'];


 double scrollPosition = 0;


 @override
 void initState() {
   super.initState();
   scrollController = ScrollController();
   scrollController.addListener(() {
     setState(() {
       scrollPosition = scrollController.offset;
     });
   });
 }


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


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('Demo'),
     ),
     body:DefaultTabController(
       length: tabs.length,
       child: Stack(
         children: [
           NestedScrollView(
             controller: scrollController,
             headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
               return <Widget>[
                 _headerSection(),
                 _tabSection(),
               ];
             },
             body: TabBarView(
               children: tabs.map((e) {
                 return NotificationListener<ScrollNotification>(
                   child: ListView.builder(
                     itemCount: 50,
                     itemBuilder: (context, index) {
                       return Container(
                         padding: const EdgeInsets.only(left: 16, top: 12, bottom: 12),
                         color: index % 2 == 0 ? Colors.white12 : Colors.white,
                         child: Text(
                           'index : $index',
                           style: const TextStyle(
                             fontSize: 16,
                           ),
                         ),
                       );
                     }
                   ),
                   onNotification: (scrollInfo) {
                     setState(() {
                       scrollPosition = scrollInfo.metrics.pixels;
                     });
                     return false;
                   },
                 );
               }).toList(),
             )
           ),
           Positioned(
             bottom: 60,
             right: 0,
             left: 0,
             child: Row(
               mainAxisSize: MainAxisSize.min,
               mainAxisAlignment: MainAxisAlignment.center,
               children: [
                 Container(
                   width: 80,
                   height: 40,
                   decoration: BoxDecoration(
                     borderRadius: BorderRadius.circular(20),
                     color: Colors.blueAccent,
                   ),
                   child: Center(
                     child: Text(
                       '${scrollPosition.ceil()}',
                       style: const TextStyle(
                         color: Colors.white,
                         fontSize: 18,
                       ),
                     ),
                   ),
                 ),
               ],
             )
           )
         ],
       ),
     )
   );
 }


 Widget _headerSection() {
   return SliverList(delegate: SliverChildListDelegate([
     Container(
       padding: const EdgeInsets.only(left: 16, top: 24, bottom: 24),
       color: Colors.red,
       child: const Text(
         'Header Section',
         style: TextStyle(
           fontSize: 24,
           color: Colors.white,
         ),
       ),
     )
   ]));
 }


 Widget _tabSection() {
   return SliverPersistentHeader(
     pinned: true,
     delegate: _StickyTabBarDelegate(
       tabBar: TabBar(
         labelColor: Colors.black,
         tabs: tabs.map((label) => Tab(text: label,)).toList(),
       )
     )
   );
 }
}


class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
 const _StickyTabBarDelegate({required this.tabBar});


 final TabBar tabBar;


 @override
 double get minExtent => tabBar.preferredSize.height;


 @override
 double get maxExtent => tabBar.preferredSize.height;


 @override
 Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
   return Container(
     color: Colors.white,
     child: tabBar,
   );
 }


 @override
 bool shouldRebuild(_StickyTabBarDelegate oldDelegate) {
   return tabBar != oldDelegate.tabBar;
 }
}
```
# 参考サイト
- https://qiita.com/naokiwakata/items/f0f475288a294ce0649e
- https://note.com/kitoko552/n/n7da7c55ebab6
- https://api.flutter.dev/flutter/widgets/NestedScrollView-class.html