Flutter. Nested scroll inside PageView

1,914

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(),
      )
    ],
  );
}
Share:
1,914
GensaGames
Author by

GensaGames

Updated on December 15, 2022

Comments

  • GensaGames
    GensaGames over 1 year

    I have simple pages with PageView widget, and inside there is ListView. And where scrolling PageView 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 the PageView and horizontal ListView.

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