Reputation: 1331
Dear Stack Overflow Community,
I need assistance in recreating a slider, as shown in the attached image. The slider should move horizontally from left to right, and the selected value should be indicated by a pointer in the middle.
I have attempted to implement this functionality using different approaches, such as ListView with GestureDetector and offset, as well as CupertinoPicker and ListWheelScrollView. However, I have not been successful in achieving the desired behavior. When using ListView, I encountered limitations in moving the slider beyond a certain point, while with CupertinoPicker and ListWheelScrollView, the slider moved in a round curve, which caused unexpected behavior when trying to flatten it using magnification and other properties.
I would like to clarify that the selection should be made by dragging the pointer horizontally and releasing it when the desired value is reached. There is no need for the lbs and kg selection, which is shown in the image.
I would be grateful for any guidance or suggestions on how to implement this slider. Thank you in advance for your assistance.
Upvotes: 2
Views: 1120
Reputation: 1
import 'package:flutter/physics.dart';
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class UnitSliderScreen extends StatefulWidget {
const UnitSliderScreen({super.key});
@override
State<UnitSliderScreen> createState() => _UnitSliderScreenState();
}
class _UnitSliderScreenState extends State<UnitSliderScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Slider Screen")),
body: const MyApp());
}
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
MeterType meterType = MeterType.kg;
CentimeterType centimeterType = CentimeterType.kg;
double meter = 5;
double centimeter = 500;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${meter.toDouble().toStringAsFixed(2)} ${meterType.name}'),
Text(
'${centimeter.toDouble().toStringAsFixed(2)} ${centimeterType.name}'),
ElevatedButton(
onPressed: () => _openBottomSheet(context),
child: const Text('Change'),
),
],
),
),
);
}
void _openBottomSheet(BuildContext context) async {
final res = await showModalBottomSheet<Tuple2<MeterType, double>>(
context: context,
elevation: 0,
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return Container(
decoration: _bottomSheetDecoration,
height: 500,
child: Column(
children: [
_Header(
meterType: meterType,
inKg: meter,
),
_Switcher(
meterType: meterType,
onChanged: (type) => setState(() => meterType = type),
),
const SizedBox(height: 10),
Expanded(
child: DivisionSlider(
meterFrom: 0,
meterMax: 10,
centimeterFrom: 0,
centimeterMax: 1000,
meterInitialValue: meter,
centimeterInitialValue: centimeter,
meterOnChanged: (value) {
setState(() {
meter = value;
});
},
centimeterOnChanged: (value) {
setState(() {
centimeter = value;
});
},
type: MeterType.kg,
),
),
],
),
);
});
},
);
if (res != null) {
setState(() {
meterType = res.item1;
meter = res.item2;
});
}
}
}
const _bottomSheetDecoration = BoxDecoration(
color: Color(0xffD9D9D9),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(30),
topRight: Radius.circular(30),
),
);
class _Header extends StatelessWidget {
const _Header({
required this.meterType,
required this.inKg,
});
final MeterType meterType;
final double inKg;
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return Padding(
padding: const EdgeInsets.all(10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
color: Colors.black54,
onPressed: () => navigator.pop(),
icon: const Icon(Icons.close),
),
const Text('meter',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
IconButton(
color: Colors.black54,
onPressed: () => navigator.pop<Tuple2<MeterType, double>>(
Tuple2(meterType, inKg),
),
icon: const Icon(Icons.check),
),
],
),
);
}
}
enum MeterType {
kg,
lb,
}
enum CentimeterType {
kg,
lb,
}
extension MeterTypeExtension on MeterType {
String get name {
switch (this) {
case MeterType.kg:
return 'kg';
case MeterType.lb:
return 'lb';
}
}
}
class _Switcher extends StatelessWidget {
final MeterType meterType;
final ValueChanged<MeterType> onChanged;
const _Switcher({
Key? key,
required this.meterType,
required this.onChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 40,
width: 250,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: [
AnimatedPositioned(
top: 2,
width: 121,
height: 36,
left: meterType == MeterType.kg ? 2 : 127,
duration: const Duration(milliseconds: 300),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 5,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
),
),
Positioned.fill(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildButton(MeterType.kg), _buildButton(MeterType.lb)],
))
],
),
);
}
Widget _buildButton(MeterType type) {
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(type),
child: Center(
child: Text(
type.name,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
),
);
}
}
class DivisionSlider extends StatefulWidget {
final double meterFrom;
final double meterMax;
final double centimeterFrom;
final double centimeterMax;
final double meterInitialValue;
final double centimeterInitialValue;
final Function(double) meterOnChanged;
final Function(double) centimeterOnChanged;
final MeterType type;
const DivisionSlider({
required this.meterFrom,
required this.meterMax,
required this.centimeterFrom,
required this.centimeterMax,
required this.meterInitialValue,
required this.centimeterInitialValue,
required this.meterOnChanged,
required this.centimeterOnChanged,
required this.type,
super.key,
});
@override
State<DivisionSlider> createState() => _DivisionSliderState();
}
class _DivisionSliderState extends State<DivisionSlider> {
PageController? meterController;
PageController? centimeterController;
final itemsExtension = 1000;
late double meterValue;
late double centimeterValue;
bool isUpdating = false;
bool isMeterUpdating = false;
bool isCentimeterUpdating = false;
@override
void initState() {
meterValue = widget.meterInitialValue;
centimeterValue = widget.centimeterInitialValue;
super.initState();
}
@override
void dispose() {
meterController?.removeListener(_updateValueMeter);
meterController?.dispose();
centimeterController?.removeListener(_updateValueCentimeter);
centimeterController?.dispose();
super.dispose();
}
void _updateValueMeter() {
if (isUpdating || isMeterUpdating) return;
isUpdating = true;
isMeterUpdating = true;
meterValue = ((((meterController?.page ?? 0) - itemsExtension) * 10)
.roundToDouble() /
10)
.clamp(widget.meterFrom, widget.meterMax);
widget.meterOnChanged(meterValue);
// Calculate centimeter value based on meter value
centimeterValue = (meterValue * 100);
widget.centimeterOnChanged(centimeterValue);
// Auto-scroll centimeter slider based on centimeter value
final centimeterPage =
((centimeterValue / 10).round() + itemsExtension).toDouble();
centimeterController
?.animateToPage(
centimeterPage.toInt(),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
.then((_) {
isUpdating = false;
isMeterUpdating = false;
});
setState(() {});
}
void _updateValueCentimeter() {
if (isUpdating || isCentimeterUpdating) return;
isUpdating = true;
isCentimeterUpdating = true;
centimeterValue =
((((centimeterController?.page ?? 0) - itemsExtension) * 10)
.roundToDouble())
.clamp(widget.centimeterFrom, widget.centimeterMax);
widget.centimeterOnChanged(centimeterValue);
// Calculate meter value based on centimeter value
meterValue = (centimeterValue / 100);
widget.meterOnChanged(meterValue);
// Auto-scroll meter slider based on meter value
final meterPage = ((meterValue).round() + itemsExtension).toDouble();
meterController
?.animateToPage(
meterPage.toInt(),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
.then((_) {
isUpdating = false;
isCentimeterUpdating = false;
});
setState(() {});
}
@override
Widget build(BuildContext context) {
assert(widget.meterInitialValue >= widget.meterFrom &&
widget.meterInitialValue <= widget.meterMax);
assert(widget.centimeterInitialValue >= widget.centimeterFrom &&
widget.centimeterInitialValue <= widget.centimeterMax);
return Container(
color: Colors.blueGrey.shade200,
child: LayoutBuilder(
builder: (context, constraints) {
final viewPortFraction = 1 / (constraints.maxWidth / 10);
meterController = PageController(
initialPage: itemsExtension + (widget.meterInitialValue.toInt()),
viewportFraction: viewPortFraction * 10,
);
centimeterController = PageController(
initialPage: itemsExtension + widget.centimeterInitialValue.toInt(),
viewportFraction: viewPortFraction * 10,
);
meterController?.addListener(_updateValueMeter);
centimeterController?.addListener(_updateValueCentimeter);
return Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.blue.shade200,
border: Border.all(color: Colors.blue, width: 1.5),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${meterValue.toDouble().toStringAsFixed(2)} bar(${widget.type.name})',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.blue.shade600,
),
),
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.red.shade200,
border: Border.all(color: Colors.red, width: 1.5),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${centimeterValue.toDouble().toStringAsFixed(2)} bar(${widget.type.name})',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.red.shade600,
),
),
),
),
],
),
const SizedBox(height: 30),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("bar (a)",
style: TextStyle(color: Colors.blue, fontSize: 20)),
Text("bar (a)",
style: TextStyle(color: Colors.blue, fontSize: 20)),
],
),
),
const SizedBox(height: 20),
_Numbers(
itemsExtension: itemsExtension,
controller: meterController,
start: widget.meterFrom,
end: widget.meterMax,
numberValue: 10,
isNumberCentimeter: false,
),
const SizedBox(height: 12),
_Numbers(
itemsExtension: itemsExtension,
controller: centimeterController,
start: widget.centimeterFrom,
end: widget.centimeterMax,
numberValue: 10,
isNumberCentimeter: true,
),
const SizedBox(height: 15),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("°C",
style: TextStyle(color: Colors.red, fontSize: 24)),
Text("°C",
style: TextStyle(color: Colors.red, fontSize: 24)),
],
),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
height: double.infinity,
width: 3,
color: Colors.blue.shade600,
),
],
),
],
);
},
),
);
}
}
class _Numbers extends StatelessWidget {
final PageController? controller;
final int itemsExtension;
final double start;
final double end;
final bool isNumberCentimeter;
final int numberValue;
const _Numbers({
required this.controller,
required this.itemsExtension,
required this.start,
required this.end,
required this.isNumberCentimeter,
required this.numberValue,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 70,
child: PageView.builder(
pageSnapping: false,
controller: controller,
physics: _CustomPageScrollPhysics(
start: isNumberCentimeter
? itemsExtension + start.toDouble() / 10
: itemsExtension + start.toDouble() * 10,
end: isNumberCentimeter
? itemsExtension + end.toDouble() / 10
: itemsExtension + end.toDouble() * 10,
),
scrollDirection: Axis.horizontal,
itemBuilder: (context, rawIndex) {
final index = rawIndex - itemsExtension;
return _Item(
index: index >= start && index <= end
? (isNumberCentimeter
? index * 10.toDouble()
: index.toDouble())
: null,
isNumberCentimeter: isNumberCentimeter,
numberValue: start);
},
),
);
}
}
class _Item extends StatelessWidget {
final double? index;
final bool isNumberCentimeter;
final double numberValue;
const _Item({
required this.index,
required this.isNumberCentimeter,
required this.numberValue,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
if (index != null && isNumberCentimeter == false)
Center(
child: Text(
'$index',
style: const TextStyle(
color: Colors.blue,
fontSize: 18,
fontWeight: FontWeight.w300),
),
),
if (index != null) Dividers(isNumberCentimeter: isNumberCentimeter),
if (index != null && isNumberCentimeter == true)
Center(
child: Text(
'$index',
style: const TextStyle(
color: Colors.red, fontSize: 18, fontWeight: FontWeight.w300),
),
),
],
);
}
}
class Dividers extends StatelessWidget {
const Dividers({super.key, required this.isNumberCentimeter});
final bool isNumberCentimeter;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 50,
child: Row(
children: List.generate(10, (index) {
final thickness = index % 5 == 0 ? 2.0 : 1.50;
return Expanded(
child: Row(
children: [
if (!isNumberCentimeter)
Transform.translate(
offset: Offset(-thickness / 2, 0),
child: VerticalDivider(
thickness: thickness,
width: 1,
indent: index % 5 == 0 && !isNumberCentimeter
? index == 5
? 1
: 14
: 24,
color: Colors.blue,
),
),
if (isNumberCentimeter)
Transform.translate(
offset: Offset(-thickness / 2, 0),
child: VerticalDivider(
thickness: thickness,
width: 1,
endIndent: index % 5 == 0 && isNumberCentimeter
? index == 5
? 1
: 14
: 24,
color: Colors.red,
),
),
],
),
);
}),
),
);
}
}
class _CustomPageScrollPhysics extends ScrollPhysics {
final double start;
final double end;
const _CustomPageScrollPhysics({
required this.start,
required this.end,
ScrollPhysics? parent,
}) : super(parent: parent);
@override
_CustomPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _CustomPageScrollPhysics(
parent: buildParent(ancestor),
start: start,
end: end,
);
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
final oldPosition = position.pixels;
final frictionSimulation =
FrictionSimulation(0.4, position.pixels, velocity * 0.2);
double newPosition = (frictionSimulation.finalX / 10).round() * 10;
final endPosition = end * 10 * 10;
final startPosition = start * 10 * 10;
if (newPosition > endPosition) {
newPosition = endPosition;
} else if (newPosition < startPosition) {
newPosition = startPosition;
}
if (oldPosition == newPosition) {
return null;
}
return ScrollSpringSimulation(
spring,
position.pixels,
newPosition.toDouble(),
velocity,
tolerance: Tolerance.defaultTolerance,
);
}
@override
SpringDescription get spring => const SpringDescription(
mass: 20,
stiffness: 100,
damping: 0.8,
);
}
Upvotes: 0
Reputation: 16225
Despite the complexity of this task, it can be achieved with more or less simple widgets.
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:tuple/tuple.dart';
void fileMain() {
runApp(
const MaterialApp(
home: MyApp(),
),
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
WeightType weightType = WeightType.kg;
double weight = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$weight ${weightType.name}'),
ElevatedButton(
onPressed: () => _openBottomSheet(context),
child: const Text('Change'),
),
],
),
),
);
}
void _openBottomSheet(BuildContext context) async {
final res = await showModalBottomSheet<Tuple2<WeightType, double>>(
context: context,
elevation: 0,
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return Container(
decoration: _bottomSheetDecoration,
height: 250,
child: Column(
children: [
_Header(
weightType: weightType,
inKg: weight,
),
_Switcher(
weightType: weightType,
onChanged: (type) => setState(() => weightType = type),
),
const SizedBox(height: 10),
Expanded(
child: DivisionSlider(
from: 0,
max: 100,
initialValue: weight,
type: weightType,
onChanged: (value) => setState(() => weight = value),
),
)
],
),
);
});
},
);
if (res != null) {
setState(() {
weightType = res.item1;
weight = res.item2;
});
}
}
}
const _bottomSheetDecoration = BoxDecoration(
color: Color(0xffD9D9D9),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(30),
topRight: Radius.circular(30),
),
);
class _Header extends StatelessWidget {
const _Header({
required this.weightType,
required this.inKg,
});
final WeightType weightType;
final double inKg;
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return Padding(
padding: const EdgeInsets.all(10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
color: Colors.black54,
onPressed: () => navigator.pop(),
icon: const Icon(Icons.close),
),
const Text('Weight',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
IconButton(
color: Colors.black54,
onPressed: () => navigator.pop<Tuple2<WeightType, double>>(
Tuple2(weightType, inKg),
),
icon: const Icon(Icons.check),
),
],
),
);
}
}
enum WeightType {
kg,
lb,
}
extension WeightTypeExtension on WeightType {
String get name {
switch (this) {
case WeightType.kg:
return 'kg';
case WeightType.lb:
return 'lb';
}
}
}
class _Switcher extends StatelessWidget {
final WeightType weightType;
final ValueChanged<WeightType> onChanged;
const _Switcher({
Key? key,
required this.weightType,
required this.onChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 40,
width: 250,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: [
AnimatedPositioned(
top: 2,
width: 121,
height: 36,
left: weightType == WeightType.kg ? 2 : 127,
duration: const Duration(milliseconds: 300),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 5,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
),
),
Positioned.fill(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildButton(WeightType.kg),
_buildButton(WeightType.lb)
],
))
],
),
);
}
Widget _buildButton(WeightType type) {
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(type),
child: Center(
child: Text(
type.name,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
),
);
}
}
class DivisionSlider extends StatefulWidget {
final double from;
final double max;
final double initialValue;
final Function(double) onChanged;
final WeightType type;
const DivisionSlider({
required this.from,
required this.max,
required this.initialValue,
required this.onChanged,
required this.type,
super.key,
});
@override
State<DivisionSlider> createState() => _DivisionSliderState();
}
class _DivisionSliderState extends State<DivisionSlider> {
PageController? numbersController;
final itemsExtension = 1000;
late double value;
@override
void initState() {
value = widget.initialValue;
super.initState();
}
void _updateValue() {
value = ((((numbersController?.page ?? 0) - itemsExtension) * 10)
.roundToDouble() /
10)
.clamp(widget.from, widget.max);
widget.onChanged(value);
}
@override
Widget build(BuildContext context) {
assert(widget.initialValue >= widget.from &&
widget.initialValue <= widget.max);
return Container(
color: Colors.white,
child: LayoutBuilder(
builder: (context, constraints) {
final viewPortFraction = 1 / (constraints.maxWidth / 10);
numbersController = PageController(
initialPage: itemsExtension + widget.initialValue.toInt(),
viewportFraction: viewPortFraction * 10,
);
numbersController?.addListener(_updateValue);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 10),
Text(
'Weight: $value ${widget.type.name}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: greenColor,
),
),
const SizedBox(height: 10),
SizedBox(
height: 10,
width: 11.5,
child: CustomPaint(
painter: TrianglePainter(),
),
),
_Numbers(
itemsExtension: itemsExtension,
controller: numbersController,
start: widget.from.toInt(),
end: widget.max.toInt(),
),
],
);
},
),
);
}
@override
void dispose() {
numbersController?.removeListener(_updateValue);
numbersController?.dispose();
super.dispose();
}
}
class TrianglePainter extends CustomPainter {
TrianglePainter();
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()..color = greenColor;
Paint paint2 = Paint()
..color = greenColor
..strokeWidth = 2
..style = PaintingStyle.stroke;
canvas.drawPath(getTrianglePath(size.width, size.height), paint);
canvas.drawPath(line(size.width, size.height), paint2);
}
Path getTrianglePath(double x, double y) {
return Path()
..lineTo(x, 0)
..lineTo(x / 2, y)
..lineTo(0, 0);
}
Path line(double x, double y) {
return Path()
..moveTo(x / 2, 0)
..lineTo(x / 2, y * 2);
}
@override
bool shouldRepaint(TrianglePainter oldDelegate) {
return true;
}
}
const greenColor = Color(0xff90D855);
class _Numbers extends StatelessWidget {
final PageController? controller;
final int itemsExtension;
final int start;
final int end;
const _Numbers({
required this.controller,
required this.itemsExtension,
required this.start,
required this.end,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 42,
child: PageView.builder(
pageSnapping: false,
controller: controller,
physics: _CustomPageScrollPhysics(
start: itemsExtension + start.toDouble(),
end: itemsExtension + end.toDouble(),
),
scrollDirection: Axis.horizontal,
itemBuilder: (context, rawIndex) {
final index = rawIndex - itemsExtension;
return _Item(index: index >= start && index <= end ? index : null);
},
),
);
}
}
class _Item extends StatelessWidget {
final int? index;
const _Item({
required this.index,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
child: Column(
children: [
const _Dividers(),
if (index != null)
Expanded(
child: Center(
child: Text(
'$index',
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
),
),
),
],
),
);
}
}
class _Dividers extends StatelessWidget {
const _Dividers({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 10,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(10, (index) {
final thickness = index == 5 ? 1.5 : 0.5;
return Expanded(
child: Row(
children: [
Transform.translate(
offset: Offset(-thickness / 2, 0),
child: VerticalDivider(
thickness: thickness,
width: 1,
color: Colors.black,
),
),
],
),
);
}),
),
);
}
}
class _CustomPageScrollPhysics extends ScrollPhysics {
final double start;
final double end;
const _CustomPageScrollPhysics({
required this.start,
required this.end,
ScrollPhysics? parent,
}) : super(parent: parent);
@override
_CustomPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _CustomPageScrollPhysics(
parent: buildParent(ancestor),
start: start,
end: end,
);
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
final oldPosition = position.pixels;
final frictionSimulation =
FrictionSimulation(0.4, position.pixels, velocity * 0.2);
double newPosition = (frictionSimulation.finalX / 10).round() * 10;
final endPosition = end * 10 * 10;
final startPosition = start * 10 * 10;
if (newPosition > endPosition) {
newPosition = endPosition;
} else if (newPosition < startPosition) {
newPosition = startPosition;
}
if (oldPosition == newPosition) {
return null;
}
return ScrollSpringSimulation(
spring,
position.pixels,
newPosition.toDouble(),
velocity,
tolerance: tolerance,
);
}
@override
SpringDescription get spring => const SpringDescription(
mass: 20,
stiffness: 100,
damping: 0.8,
);
}
Upvotes: 4