If you want to use a ListView
, and your items are of fixed with, you can use an implementation of ScrollPhysics
based on PageScrollPhysics
used by the PageView
.
This has the limitation that it only works for equally sized children.
import 'package:flutter/material.dart';
class SnapScrollPhysics extends ScrollPhysics {
const SnapScrollPhysics({super.parent, required this.snapSize});
final double snapSize;
@override
SnapScrollSize applyTo(ScrollPhysics? ancestor) {
return SnapScrollSize(parent: buildParent(ancestor), snapSize: snapSize);
}
double _getPage(ScrollMetrics position) {
return position.pixels / snapSize;
}
double _getPixels(ScrollMetrics position, double page) {
return page * snapSize;
}
double _getTargetPixels(
ScrollMetrics position, Tolerance tolerance, double velocity) {
double page = _getPage(position);
if (velocity < -tolerance.velocity) {
page -= 0.5;
} else if (velocity > tolerance.velocity) {
page += 0.5;
}
return _getPixels(position, page.roundToDouble());
}
@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 Tolerance tolerance = this.tolerance;
final double target = _getTargetPixels(position, tolerance, velocity);
if (target != position.pixels) {
return ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);
}
return null;
}
@override
bool get allowImplicitScrolling => false;
}
You can see it in action here:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: ListView(
physics: SnapScrollPhysics(snapSize: MediaQuery.of(context).size.width/3),
scrollDirection: Axis.horizontal,
children: <Widget>[
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[900],
child: const Center(child: Text('Entry A')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[800],
child: const Center(child: Text('Entry B')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[700],
child: const Center(child: Text('Entry C')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[600],
child: const Center(child: Text('Entry D')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[500],
child: const Center(child: Text('Entry E')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[400],
child: const Center(child: Text('Entry F')),
),
Container(
width: MediaQuery.of(context).size.width/3,
color: Colors.amber[300],
child: const Center(child: Text('Entry G')),
),
],
),
);
}
}
class SnapScrollSize extends ScrollPhysics {
const SnapScrollSize({super.parent, required this.snapSize});
final double snapSize;
@override
SnapScrollSize applyTo(ScrollPhysics? ancestor) {
return SnapScrollSize(parent: buildParent(ancestor), snapSize: snapSize);
}
double _getPage(ScrollMetrics position) {
return position.pixels / snapSize;
}
double _getPixels(ScrollMetrics position, double page) {
return page * snapSize;
}
double _getTargetPixels(
ScrollMetrics position, Tolerance tolerance, double velocity) {
double page = _getPage(position);
if (velocity < -tolerance.velocity) {
page -= 0.5;
} else if (velocity > tolerance.velocity) {
page += 0.5;
}
return _getPixels(position, page.roundToDouble());
}
@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 Tolerance tolerance = this.tolerance;
final double target = _getTargetPixels(position, tolerance, velocity);
if (target != position.pixels) {
return ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);
}
return null;
}
@override
bool get allowImplicitScrolling => false;
}