5
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?

DeNA 25 新卒Advent Calendar 2024

Day 17

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

Last updated at Posted at 2024-12-16

はじめに

Flutterでアプリ開発をしていて、NestedScrollViewの内側のスクロール量を取得する際に少々苦労したので、今回はそのことについてご紹介できればと思います。結論だけ知りたい方は解決策に飛んでいただければと思います。

NestedScrollViewとは?

複数のスクロール可能なビューを入れ子にすることができ、スクロールをリンクさせることができるウィジェットです。

前提条件

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

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

Simulator Screen Recording - iPhone 15 - 2024-12-08 at 18.48.51.gif

こちらのサンプルコードを以下に示します。(前後省略しています)

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

解決策

内側のスクロール要素に対してNotificationListenerをセットすることで解決できます。
以下にサンプルコードを示します。

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

全体のコードは以下の通りです

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

参考サイト

5
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
5
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?