Reputation: 1207
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
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
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
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