はじめに
Flutterでアプリ開発をしていて、NestedScrollViewの内側のスクロール量を取得する際に少々苦労したので、今回はそのことについてご紹介できればと思います。結論だけ知りたい方は解決策に飛んでいただければと思います。
NestedScrollViewとは?
複数のスクロール可能なビューを入れ子にすることができ、スクロールをリンクさせることができるウィジェットです。
前提条件
今回開発しているアプリの構成は以下のような感じです。DefaultTabControllerの中にNestedScrollViewを埋め込んでいます。ページ全体(緑枠の部分)がスクロール可能なビューとなっており、TabView(紫枠の部分)にまたスクロール要素が埋め込まれているような構成になっています。
TabViewをスクロールすると連動してページ全体がスクロールされます(下図)。この時、HeaderSectionは画面上部に隠れ、TabBarはAppBarに引っ付くようになっています。また、画面下部にはNestedScrollViewのスクロール量を表示しています。
こちらのサンプルコードを以下に示します。(前後省略しています)
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を設定すれば内側のスクロール量が取れるのではないかと思いつくかもしれません。この場合内側のスクロール量を取得することはできます。一方で、外側のスクロール要素とのリンクが切れてしまい、内側をスクロールしても外側のスクロールとは連動しなくなります(下図)。
解決策
内側のスクロール要素に対して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,
),
),
),
),
],
)
)
],
),
)
無事スクロールが連動したまま、内側のスクロール量を取得することができました!
全体のコードは以下の通りです
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;
}
}