4

As the title says, we wanna put a vertical ListView inside a vertical PageView and make them scroll smoothly,

We will achieve something like that:

enter image description here

Tayan
  • 1,707
  • 13
  • 18

2 Answers2

7

The Concept:

When the user scrolls the list, if they reach its bottom and scroll in the same direction again, we want the page to scroll to the next one not the list. And vice versa.

To achieve that we are gonna handle the scrolling of both widgets manually, depending on the touch gestures of the user.

The Code:

Firstly, in the state of the parent widget, declare these fields.

PageController pageController;
ScrollController activeScrollController;
Drag drag;

//These variables To detect if we are at the
//top or bottom of the list.
bool atTheTop;
bool atTheBottom;

Then initialize and dispose them:

@override
void initState() {
  super.initState();

  pageController = PageController();

  atTheTop = true;
  atTheBottom = false;
}

@override
void dispose() {
  pageController.dispose();

  super.dispose();
}

now let's create five methods for handling the vertical dragging of the user.

void handleDragStart(DragStartDetails details, ScrollController 
scrollController) {
  if (scrollController.hasClients) {
    if (scrollController.position.context.storageContext != null) {
      if (scrollController.position.pixels == scrollController.position.minScrollExtent) {
        atTheTop = true;
      } else if (scrollController.position.pixels == scrollController.position.maxScrollExtent) {
        atTheBottom = true;
      } else {
        atTheTop = false;
        atTheBottom = false;

        activeScrollController = scrollController;
        drag = activeScrollController.position.drag(details, disposeDrag);
        return;
      }
    }
  }

  activeScrollController = pageController;
  drag = pageController.position.drag(details, disposeDrag);
}

void handleDragUpdate(DragUpdateDetails details, ScrollController 
scrollController) {
  if (details.delta.dy > 0 && atTheTop) {
    //Arrow direction is to the bottom.
    //Swiping up.

    activeScrollController = pageController;
    drag?.cancel();
    drag = pageController.position.drag(
        DragStartDetails(globalPosition: details.globalPosition, localPosition: details.localPosition),
        disposeDrag);
  } else if (details.delta.dy < 0 && atTheBottom) {
    //Arrow direction is to the top.
    //Swiping down.

    activeScrollController = pageController;
    drag?.cancel();
    drag = pageController.position.drag(
        DragStartDetails(
          globalPosition: details.globalPosition,
          localPosition: details.localPosition,
        ),
        disposeDrag);
  } else {
    if (atTheTop || atTheBottom) {
      activeScrollController = scrollController;
      drag?.cancel();
      drag = scrollController.position.drag(
          DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition,
          ),
          disposeDrag);
    }
  }
  drag?.update(details);
}

void handleDragEnd(DragEndDetails details) {
  drag?.end(details);

  if (atTheTop) {
    atTheTop = false;
  } else if (atTheBottom) {
    atTheBottom = false;
  }
}

void handleDragCancel() {
  drag?.cancel();
}

void disposeDrag() {
  drag = null;
}

And Finally, let's build the widgets:

PageView:

@override
Widget build(BuildContext context) {
  return PageView(
    controller: pageController,
    scrollDirection: Axis.vertical,
    physics: const NeverScrollableScrollPhysics(),
    children: [
      MyListView(
        handleDragStart: handleDragStart,
        handleDragUpdate: handleDragUpdate,
        handleDragEnd: handleDragEnd,
        pageStorageKeyValue: '1', //Should be unique for each widget.
      ),
      ...
    ],
  );
}

ListView:

class MyListView extends StatefulWidget {
  const MyListView({
    Key key,
    @required this.handleDragStart,
    @required this.handleDragUpdate,
    @required this.handleDragEnd,
    @required this.pageStorageKeyValue,
  })  : assert(handleDragStart != null),
        assert(handleDragUpdate != null),
        assert(handleDragEnd != null),
        assert(pageStorageKeyValue != null),
        super(key: key);

  final ValuesChanged<DragStartDetails, ScrollController> handleDragStart;
  final ValuesChanged<DragUpdateDetails, ScrollController> handleDragUpdate;
  final ValueChanged<DragEndDetails> handleDragEnd;
  
  //Notice here, the key to save the position scroll of the list.
  final String pageStorageKeyValue;

  @override
  _MyListViewState createState() => _MyListViewState();
}

class _MyListViewState extends State<MyListView> {
  ScrollController scrollController;

  @override
  void initState() {
    super.initState();

    scrollController = ScrollController();
  }

  @override
  void dispose() {
    scrollController.dispose();

    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onVerticalDragStart: (details) {
        widget.handleDragStart(details, scrollController);
      },
      onVerticalDragUpdate: (details) {
        widget.handleDragUpdate(details, scrollController);
      },
      onVerticalDragEnd: widget.handleDragEnd,
      child: ListView.separated(
        key: PageStorageKey<String>(widget.pageStorageKeyValue),
        physics: const NeverScrollableScrollPhysics(),
        controller: scrollController,
        itemCount: 15,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
        separatorBuilder: (context, index) {
          return const Divider(
            thickness: 3,
          );
        },
      ),
    );
  }
}

typedef for injecting the methods:

typedef ValuesChanged<T, E> = void Function(T value, E valueTwo);

Notes:

  • Notice the using of PageStorageKey in the ListView, so that we can save the scroll position of the list if the user scrolls back to the previous page.

References:

If you have anything to say, I'm here to reply. Thanks.

Tayan
  • 1,707
  • 13
  • 18
  • Can I use this controller with 'pageSnapping: false'? when I tried that, it doesn't scroll with error that 'ScrollController attatched to multiple scroll views'. – 이상진 May 24 '21 at 04:26
  • Sorry, I don't understand your comment clearly. You mean that when you set `pageSnapping:false` you doesn't encounter that error and the scroll works fine? – Tayan May 26 '21 at 21:04
  • @이상진 Anyway, I've updated the code above to fix that exception. – Tayan May 26 '21 at 21:04
  • Would there be a way to make the list views have bouncingScrollPhysics() only at the ends ,,, I tried to setState an additional bool _canBounce variable,,, but it messed up things,, once it rebuilds in setstate,, scroll position snaps to zero,,, I'm lost how to fix that ,, any ideas ? – Rageh Azzazy Jul 19 '21 at 00:30
1

I've modified Tayan's response to suit cases when scrolling view are too small to fit entire screen. I've added horizontal orientation support also and moved drag handling logic to the widget itself. Also there is support for any kind of scrolling view (not only ListView) Here is the code:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

///This widget provides correct scrolling and swiping behavior when scrolling view are placed inside pageview with same direction
///The widget works both for vertical and horizontal scrolling direction
///To use this widget you have to do following:
///* set physics: NeverScrollableScrollPhysics(parent: ClampingScrollPhysics()) argument for both PageView and ScrollView
///* create scrollController for ScrollView and pageController for PageView. Do not forget to dispose then at dispose() State callback
///* make sure that scrolling direction on both views are the same and equals to scrollDirection argument here

class PageViewScrollableChild extends StatefulWidget {
  final Widget child;
  final ScrollController scrollController;
  final PageController pageController;
  final Axis scrollDirection;

  const PageViewScrollableChild(
      {Key? key,
      required this.scrollController,
      required this.pageController,
      required this.child,
      required this.scrollDirection})
      : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _PageViewScrollableChildState();
  }
}

class _PageViewScrollableChildState extends State<PageViewScrollableChild> {
  late bool atTheStart;
  late bool atTheEnd;
  ///true if scroll view content does not overscroll screen size
  late bool bothSides;

  ScrollController? activeScrollController;
  Drag? drag;

  @override
  void initState() {
    super.initState();

    atTheStart = true;
    atTheEnd = false;
    bothSides = false;
  }

  void handleDragStart(DragStartDetails details, ScrollController scrollController) {
    if (scrollController.hasClients) {
      if (scrollController.position.minScrollExtent == 0 && scrollController.position.maxScrollExtent == 0) {
        bothSides = true;
      } else if (scrollController.position.pixels <= scrollController.position.minScrollExtent) {
        atTheStart = true;
      } else if (scrollController.position.pixels >= scrollController.position.maxScrollExtent) {
        atTheEnd = true;
      } else {
        atTheStart = false;
        atTheEnd = false;

        activeScrollController = scrollController;
        drag = activeScrollController?.position.drag(details, disposeDrag);
        return;
      }
    }

    activeScrollController = widget.pageController;
    drag = widget.pageController.position.drag(details, disposeDrag);
  }

  void handleDragUpdate(DragUpdateDetails details, ScrollController scrollController) {
    final offset = widget.scrollDirection == Axis.vertical ? details.delta.dy : details.delta.dx;
    if (offset > 0 && (atTheStart || bothSides)) {
      //Arrow direction is to the bottom.
      //Swiping up.

      activeScrollController = widget.pageController;
      drag?.cancel();
      drag = widget.pageController.position.drag(
          DragStartDetails(globalPosition: details.globalPosition, localPosition: details.localPosition), disposeDrag);
    } else if (offset < 0 && (atTheEnd || bothSides)) {
      //Arrow direction is to the top.
      //Swiping down.

      activeScrollController = widget.pageController;
      drag?.cancel();
      drag = widget.pageController.position.drag(
          DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition,
          ),
          disposeDrag);
    } else if (atTheStart || atTheEnd) {
      activeScrollController = scrollController;
      drag?.cancel();
      drag = scrollController.position.drag(
          DragStartDetails(
            globalPosition: details.globalPosition,
            localPosition: details.localPosition,
          ),
          disposeDrag);
    }

    drag?.update(details);
  }

  void handleDragEnd(DragEndDetails details) {
    drag?.end(details);

    if (atTheStart) {
      atTheStart = false;
    } else if (atTheEnd) {
      atTheEnd = false;
    }
  }

  void handleDragCancel() {
    drag?.cancel();
  }

  void disposeDrag() {
    drag = null;
  }

  @override
  Widget build(BuildContext context) {
    final scrollDirection = widget.scrollDirection;
    return GestureDetector(
      onVerticalDragStart:
          scrollDirection == Axis.vertical ? (details) => handleDragStart(details, widget.scrollController) : null,
      onVerticalDragUpdate:
          scrollDirection == Axis.vertical ? (details) => handleDragUpdate(details, widget.scrollController) : null,
      onVerticalDragEnd: scrollDirection == Axis.vertical ? (details) => handleDragEnd(details) : null,
      onHorizontalDragStart:
          scrollDirection == Axis.horizontal ? (details) => handleDragStart(details, widget.scrollController) : null,
      onHorizontalDragUpdate:
          scrollDirection == Axis.horizontal ? (details) => handleDragUpdate(details, widget.scrollController) : null,
      onHorizontalDragEnd: scrollDirection == Axis.horizontal ? (details) => handleDragEnd(details) : null,
      child: widget.child,
    );
  }
}