Hannes Hultergård
Hannes Hultergård

Reputation: 1207

Scroll multiple pages in PageView easier

I am building a horizontally scrollable list in flutter using PageView, however, I would like to be able to scroll multiple pages at the same time. It is currently possible if I scroll really fast, but it is far from ideal. Another option is to set pageSnapping to false, but then it does not snap at all, which is not what I want.

I am thinking that it might be possible to change pageSnapping from false to true if the scroll velocity is under a certain treshhold, but i don't know how I would get said velocity.

The app i am building looks something like this.

All help appreciated!

Upvotes: 4

Views: 3293

Answers (3)

Paul
Paul

Reputation: 1645

The easiest and most natural way is to customize the physics of the PageView. You can set velocityPerOverscroll (logical pixels per second) according to your needs.

PageView(
   pageSnapping: false,
   physics: const PageOverscrollPhysics(velocityPerOverscroll: 1000),

Extend ScrollPhysics to control the snap behavior:

class PageOverscrollPhysics extends ScrollPhysics {
  ///The logical pixels per second until a page is overscrolled.
  ///A satisfying value can be determined by experimentation.
  ///
  ///Example:
  ///If the user scroll velocity is 3500 pixel/second and [velocityPerOverscroll]=
  ///1000, then 3.5 pages will be overscrolled/skipped.
  final double velocityPerOverscroll;

  const PageOverscrollPhysics({
    ScrollPhysics? parent,
    this.velocityPerOverscroll = 1000,
  }) : super(parent: parent);

  @override
  PageOverscrollPhysics applyTo(ScrollPhysics? ancestor) {
    return PageOverscrollPhysics(
      parent: buildParent(ancestor)!,
    );
  }

  double _getTargetPixels(ScrollMetrics position, double velocity) {
    double page = position.pixels / position.viewportDimension;
    page += velocity / velocityPerOverscroll;
    double pixels = page.roundToDouble() * position.viewportDimension;
    return pixels;
  }

  @override
  Simulation? createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }
    final double target = _getTargetPixels(position, velocity);
    if (target != position.pixels) {
      return ScrollSpringSimulation(spring, position.pixels, target, velocity,
          tolerance: tolerance);
    }
    return null;
  }

  @override
  bool get allowImplicitScrolling => false;
}

Note that it is also required to set pageSnapping: false, this is done intentionally to disable the internal physics of PageView, which we are overwriting with PageOverscrollPhysics. Although PageOverscrollPhysics might look crazy complicated, it's essentially just an adjustment of the PageScrollPhysics()class.

Upvotes: 0

Tor-Martin Holen
Tor-Martin Holen

Reputation: 1639

Interesting problem!

To gain the velocity of a swipe you can use a GestureDetector, unfortunately when trying to use both a GestureDetector and PageView then the PageView steals the focus from the GestureDetector so they can not be used in unison.

GestureDetector(
  onPanEnd: (details) {
    Velocity velocity = details.velocity;
    print("onPanEnd - velocity: $velocity");
  },
)

Another way however was to use DateTime in the PageView's onPageChanged to measure the change in time, instead of velocity. However this is not ideal, the code I wrote is like a hack. It has a bug (or feature) that the first swipe after a standstill on a page will only move one page, however consecutive swipes will be able to move multiple pages.

bool pageSnapping = true;
List<int> intervals = [330, 800, 1200, 1600]; // Could probably be optimised better
DateTime t0;

Widget timeBasedPageView(){
  return PageView(
    onPageChanged: (item) {

      // Obtain a measure of change in time.
      DateTime t1 = t0 ?? DateTime.now();
      t0 = DateTime.now();
      int millisSincePageChange = t0.difference(t1).inMilliseconds;
      print("Millis: $millisSincePageChange");

      // Loop through the intervals, they only affect how much time is 
      // allocated before pageSnapping is enabled again. 
      for (int i = 1; i < intervals.length; i++) {
        bool lwrBnd = millisSincePageChange > intervals[i - 1];
        bool uprBnd = millisSincePageChange < intervals[i];
        bool withinBounds = lwrBnd && uprBnd;

        if (withinBounds) {
          print("Index triggered: $i , lwr: $lwrBnd, upr: $uprBnd");

          // The two setState calls ensures that pageSnapping will 
          // always return to being true.
          setState(() {
            pageSnapping = false;
          });

          // Allows some time for the fast pageChanges to proceed 
          // without being pageSnapped.
          Future.delayed(Duration(milliseconds: i * 100)).then((val){
            setState(() {
              pageSnapping = true;
            });
          });
        }
      }
    },
    pageSnapping: pageSnapping,
    children: widgets,
  );
}

I hope this helps in some way.

Edit: another answer based upon Hannes' answer.

class PageCarousel extends StatefulWidget {
  @override
  _PageCarouselState createState() => _PageCarouselState();
}

class _PageCarouselState extends State<PageCarousel> {
  int _currentPage = 0;
  PageController _pageController;
  int timePrev; //Tid
  double posPrev; //Position
  List<Widget> widgets = List.generate(
      10,
          (item) =>
          Container(
            padding: EdgeInsets.all(8),
            child: Card(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Text("Index $item"),
                ],
              ),
            ),
          ));

  @override
  void initState() {
    super.initState();
    _pageController = PageController(
      viewportFraction: 0.75,
      initialPage: 0,
    );
  }

  int boundedPage(int newPage){
    if(newPage < 0){
      return 0;
    }
    if(newPage >= widgets.length){
      return widgets.length - 1;
    }
    return newPage;
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Listener(
        onPointerDown: (pos){
          posPrev = pos.position.dx;
          timePrev = DateTime.now().millisecondsSinceEpoch;
          print("Down");
          print("Time: $timePrev");
        },
        onPointerUp: (pos){
          int newTime = DateTime.now().millisecondsSinceEpoch;
          int timeDx = newTime - timePrev;
          double v = (posPrev - pos.position.dx) / (timeDx);
          int newPage = _currentPage + (v * 1.3).round();

          print("Velocity: $v");
          print("New Page: $newPage, Old Page: $_currentPage");

          if (v < 0 && newPage < _currentPage || v >= 0 && newPage > _currentPage) {
            _currentPage = boundedPage(newPage);
          }

          _pageController.animateToPage(_currentPage,
              duration: Duration(milliseconds: 800), curve: Curves.easeOutCubic);
        },
        child: PageView(
          controller: _pageController,
          physics: ClampingScrollPhysics(), //Will scroll to far with BouncingScrollPhysics
          scrollDirection: Axis.horizontal,
          children: widgets,
        ),
      ),
    );
  }
}

This should ensure a decent multi page navigation.

Upvotes: 5

Hannes Hulterg&#229;rd
Hannes Hulterg&#229;rd

Reputation: 1207

To anyone coming here in the future, i finally solved this using a Listener insted of a GestureDetector to calculate the code manually.

Here is the relevant code:

class HomeWidget extends StatefulWidget {
  @override
  _HomeWidgetState createState() => _HomeWidgetState();
}

class _HomeWidgetState extends State<HomeWidget> {
  int _currentPage = 0;
  PageController _pageController;
  int t; //Tid
  double p; //Position

  @override
  initState() {
    super.initState();
    _pageController = PageController(
      viewportFraction: 0.75,
      initialPage: 0,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Listener(
        onPointerMove: (pos) { //Get pointer position when pointer moves
          //If time since last scroll is undefined or over 100 milliseconds
          if (t == null || DateTime.now().millisecondsSinceEpoch - t > 100) {
            t = DateTime.now().millisecondsSinceEpoch;
            p = pos.position.dx; //x position
          } else {
            //Calculate velocity
            double v = (p - pos.position.dx) / (DateTime.now().millisecondsSinceEpoch - t);
            if (v < -2 || v > 2) { //Don't run if velocity is to low
              //Move to page based on velocity (increase velocity multiplier to scroll further)
              _pageController.animateToPage(_currentPage + (v * 1.2).round(),
                  duration: Duration(milliseconds: 800), curve: Curves.easeOutCubic);
            }
          }
        },
        child: PageView(
          controller: _pageController,
          physics: ClampingScrollPhysics(), //Will scroll to far with BouncingScrollPhysics
          scrollDirection: Axis.horizontal,
          children: <Widget>[
            //Pages
          ],
        ),
      ),
    );
  }
}

Upvotes: 7

Related Questions