Flutter. Nested scroll inside PageView
Solution 1
I got stuck in a similar scenario recently and after some research and reading about how gestures work under the hood in flutter, I found a good enough working solution.
So I used RawGestureDetector which gives you a low level widget handling of gestures and disabled the scrolling for PageView and ListView by setting their physics
to NeverScrollableScrollPhysics
. This meant scrolling these widgets using their respective controllers - PageViewController
and ScrollController
. Now according to position and state of ListView, an active controller of the two is chosen and dragged using the controller. The main class would look something like this.
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(appBar: AppBar(), body: NestedContainerWidget()));
}
}
class NestedContainerWidget extends StatefulWidget {
@override
_NestedContainerWidgetState createState() => _NestedContainerWidgetState();
}
class _NestedContainerWidgetState extends State<NestedContainerWidget> {
PageController _pageController;
ScrollController _listScrollController;
ScrollController _activeScrollController;
Drag _drag;
@override
void initState() {
super.initState();
_pageController = PageController();
_listScrollController = ScrollController();
}
@override
void dispose() {
_pageController.dispose();
_listScrollController.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
if (_listScrollController.hasClients &&
_listScrollController.position.context.storageContext != null) {
final RenderBox renderBox =
_listScrollController.position.context.storageContext.findRenderObject();
if (renderBox.paintBounds
.shift(renderBox.localToGlobal(Offset.zero))
.contains(details.globalPosition)) {
_activeScrollController = _listScrollController;
_drag = _activeScrollController.position.drag(details, _disposeDrag);
return;
}
}
_activeScrollController = _pageController;
_drag = _pageController.position.drag(details, _disposeDrag);
}
void _handleDragUpdate(DragUpdateDetails details) {
if (_activeScrollController == _listScrollController &&
details.primaryDelta < 0 &&
_activeScrollController.position.pixels ==
_activeScrollController.position.maxScrollExtent) {
_activeScrollController = _pageController;
_drag?.cancel();
_drag = _pageController.position.drag(
DragStartDetails(
globalPosition: details.globalPosition, localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}
void _handleDragEnd(DragEndDetails details) {
_drag?.end(details);
}
void _handleDragCancel() {
_drag?.cancel();
}
void _disposeDrag() {
_drag = null;
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(), (VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
physics: const NeverScrollableScrollPhysics(),
children: [
Center(child: Text('Page 1')),
ListView(
controller: _listScrollController,
physics: const NeverScrollableScrollPhysics(),
children: List.generate(
20,
(int index) {
return ListTile(title: Text('Item $index'));
},
),
),
],
),
);
}
}
Look at the logic in _handleDragStart
and _handleDragUpdate
which determines the controller that should be scrolled according to the state of the ListView
widget and it's scrolling state.
I wrote a detailed article on this problem, you can check it here - https://medium.com/@Mak95/nested-scrolling-listview-inside-pageview-in-flutter-a57b7a6241b1
The solution can be improved, so inputs would be much welcome.
Solution 2
I used this answer to help me fix the same problem.
basically, you listen to the scrollable child for an OverscrollNotification
using NotificationListener
and scroll the parent using its controller.
code:
final pageCntrler = PageController();
_scrollDown() async {
await pageCntrler.nextPage(
duration: scrollDuration,
curve: scrollCurve,
);
}
_scrollUp() async {
await pageCntrler.previousPage(
duration: scrollDuration,
curve: scrollCurve,
);
}
@override
Widget build(BuildContext context) {
return PageView(
controller: pageCntrler,
scrollDirection: Axis.vertical,
physics: NeverScrollableScrollPhysics(),
children: [
NotificationListener(
onNotification: (notification) {
if (notification is OverscrollNotification) {
if (notification.overscroll > 0) {
_scrollDown();
} else {
_scrollUp();
}
}
},
child: SingleChildScrollView(),
)
],
);
}
GensaGames
Updated on December 15, 2022Comments
-
GensaGames over 1 year
I have simple pages with
PageView
widget, and inside there isListView
. And where scrollingPageView
will not work. The reason is simple. Because pointer event consumed by nested child.@override Widget build(BuildContext context) { setupController(); return PageView( controller: controllerPage, scrollDirection: Axis.vertical, children: <Widget>[ ListView.builder( controller: controller, padding: EdgeInsets.all(AppDimens.bounds), itemCount: 15, itemBuilder: (context, index){ return Container( height: 100, color: index %2 == 0 ? Colors.amber : Colors.blueAccent, ); }, ), Container(color: Colors.green), Container(color: Colors.blue), ], ); }
My question is there any sane way to make it works together? You might see vertical axis for the
PageView
, but exactly the same issue would appear by using horizontal axis of thePageView
and horizontalListView
.What I have tried so far? I have some workaround for it. Even it's not complicated, it's just feels not so good and clunky. By using
AbsorbPointer
and custom controllers for the scrolling.final controller = ScrollController(); final controllerPage = PageController(keepPage: true); bool hasNestedScroll = true; void setupController() { controller.addListener(() { if (controller.offset + 5 > controller.position.maxScrollExtent && !controller.position.outOfRange) { /// Swap to Inactive, if it was not if (hasNestedScroll) { setState(() { hasNestedScroll = false; }); } } else { /// Swap to Active, if it was not if (!hasNestedScroll) { setState(() { hasNestedScroll = true; }); } } }); controllerPage.addListener(() { if (controllerPage.page == 0) { setState(() { hasNestedScroll = true; }); } }); } @override Widget build(BuildContext context) { setupController(); return PageView( controller: controllerPage, scrollDirection: Axis.vertical, children: <Widget>[ AbsorbPointer( absorbing: !hasNestedScroll, child: ListView.builder( controller: controller, padding: EdgeInsets.all(AppDimens.bounds), itemCount: 15, itemBuilder: (context, index){ return Container( height: 100, color: index %2 == 0 ? Colors.amber : Colors.blueAccent, ); }, ), ), Container(color: Colors.green), Container(color: Colors.blue), ], ); }