Nested Scrolling ListView inside PageView in Flutter
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 callbacksGestureDetector
provides. TheGestureFactories
are then passed on to theRawGestureDetector
.
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.