Nested Scrolling ListView inside PageView in Flutter

Mayank Khandelwal
4 min readJul 18, 2020
Photo by June Admiraal on Unsplash

A few days ago, I came across a challenge in which I had to use a vertically scrolling ListView inside a vertically scrolling PageView. Now, if you are or have been stuck at this kind of problem, you’d have noticed that by default the ListView captures the gestures, even when we are at top or bottom of the list. This is because HitTestBehavior of GestureDetector is set to deferToChild by default, which essentially passes the control down the widget tree and thus it is the child (in this case, ListView), which captures the touch/drag events. More on this later.

While hunting for a solution, I tried to learn about how the gestures work in Flutter under the hood. Let’s say we are using GestureDetector, then the flow of events is something like —

Inside of GestureDetector, a Gesture Factory is created. Gesture Recognizer does the hard work of determining what gesture is being handled. This process is the same for all of the different callbacks GestureDetector provides. The GestureFactories are then passed on to the RawGestureDetector.

RawGestureDetector does the hard work of detecting the gestures. It is a stateful widget which syncs all gestures when the state changes, disposes of the recognizers, takes all the pointer events that occur and sends it to the recognizers registered. They then battle it out in Gesture Arena.

You can read more about Gestures and how multiple gestures are handled by the RawGestureDetector in this amazing article by Nash. The above excerpt is taken from the same article.

We’ll be making the following app for demonstration. The following GIF shows a vertically scrolling PageView which contains 2 pages, and among that the second page contains a ListView.

The main job at hand is to figure out how to let the PageViewController and ListViewController know of the intention of the drag, i.e. when we are at ends of the scrolling list, the gesture should be handled by PageViewController, otherwise by ListViewController. One way was to allow both gestures in GestureArena and deciding the winner according to the scroll position of our widgets.
However, I went with the second approach which was to use a RawGestureDetector and handle the gesture events. This meant disabling the default scrolling of ListView and PageView by using NeverScrollableScrollPhysics() as the scrolling physics behavior for both the widgets. And finally, using their respective controllers instead for dragging/scrolling them.

Alright, enough talk. Time for some code.

@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'));
})),
],
),
);
}

Let’s break down the above code.
- The RawGestureDetector requires gestures which is a Map of GestureRecognizer type as Key and GestureRecognizerFactory as Value.
- The GestureRecognizerFactory requires a constructor and initializer. In the initializer, we handle the gesture behaviors for onStart, onUpdate, onEnd and onCancel. Let’s check these methods.

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.minScrollExtent) {
_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;
}

Breaking down the above methods, we can see
- _handleDragStart assigns which controller is active by determining on ListView if it is in focus based on the position of RenderBox. Otherwise, the activeScrollController is assigned to controller of PageView.
- _handleDragUpdate checks if the ListView is scrolling and when it reaches the top, then the current drag is cancelled and the _activeScrollController is assigned to controller of PageView, so that on next drag the PageView is scrolled.
- Other methods such as _handleDragCancel and _handleDragEnd are self explanatory.

And that’s it. You can find the complete code here.
Major credit goes to the author of the code snippet I found here, which was the inspiration for writing this article and compiling resources.

If you have any other questions or suggestions, please comment below. If this article was helpful for you, please press the clap buttons as many times as you wish to. Thanks! Peace.

--

--

Mayank Khandelwal

Trying to go big because playing small never serves the world. Know more about me - https://tinteduniverse.com/