I want to implement this slider that'll allow user to select the time. The bars that represent quarter, half and full hours should get purple (same color as the pin/anchor) when they are in the anchor's area. I'm using this as a component in a vertically scrollable screen.
I've also attached my current implementation. But I'm stuck on how to get the time value and how to change the color of the current bar.
class FixedPinTimePicker extends StatefulWidget {
FixedPinTimePicker({super.key, required this.width});
double width;
@override
State<FixedPinTimePicker> createState() => _FixedPinTimePickerState();
}
Widget hourBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 100,
width: 5,
color: Colors.grey,
),
);
}
Widget halfHourBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 70,
width: 5,
color: Colors.grey,
),
);
}
Widget quarterHourBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 50,
width: 5,
color: Colors.grey,
),
);
}
Widget getBarBasedOnIndex(int index) {
if (index % 4 == 0) {
return halfHourBar();
} else if (index % 2 == 0) {
return hourBar();
} else {
return quarterHourBar();
}
}
String getTimeBasedOnIndex(int index) {
final int hour = index ~/ 2;
final int minute = index % 2 == 0 ? 0 : 30;
return index % 2 == 0 ? '$hour:$minute' : '';
}
class _FixedPinTimePickerState extends State<FixedPinTimePicker> {
ScrollController _scrollController = ScrollController();
@override
Widget build(BuildContext context) {
log("Width: ${widget.width}");
return SizedBox(
height: 150,
child: Stack(
children: [
ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: 49,
itemBuilder: (context, index) {
log("Controller: ${_scrollController.offset}");
return Stack(
children: [
getBarBasedOnIndex(index),
Positioned(
bottom: 0,
child: Text(
getTimeBasedOnIndex(index),
style: TextStyle(
color: Colors.black,
fontSize: 20,
),
),
),
],
);
},
),
Positioned(
top: 0,
right: widget.width / 2 - 40,
child: CustomPaint(
painter: InvertedTrianglePainter(),
size: Size(30, 60),
)),
],
),
);
}
}
import 'package:flutter/material.dart';
class FixedPinTimePicker extends StatefulWidget {
const FixedPinTimePicker({
super.key,
required this.width,
required this.onTimeSelected,
});
final double width;
final ValueChanged<String> onTimeSelected;
@override
State<FixedPinTimePicker> createState() => _FixedPinTimePickerState();
}
class _FixedPinTimePickerState extends State<FixedPinTimePicker> {
final ScrollController _scrollController = ScrollController();
int _currentHighlightedIndex = 0;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Widget _buildBar(int index, bool isHighlighted) {
Color barColor = isHighlighted ? Colors.purple : Colors.grey;
if (index % 4 == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 100,
width: 5,
color: barColor,
),
);
} else if (index % 2 == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(height: 70, width: 5, color: barColor),
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(height: 50, width: 5, color: barColor),
);
}
}
String _getTimeBasedOnIndex(int index) {
final int hour = index ~/ 2;
final int minute = index % 2 == 0 ? 0 : 30;
return index % 2 == 0 ? '$hour:$minute' : '';
}
void _updateHighlightedIndex() {
double anchorPosition = widget.width / 2; // Center of the widget
double closestDistance = double.infinity;
int closestIndex = 0;
for (int i = 0; i < 49; i++) {
double barPosition = (i * 45) +
5 -
_scrollController.offset; // Assuming each bar has a width of 50
double distance = (barPosition - anchorPosition).abs();
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
if (_currentHighlightedIndex != closestIndex) {
setState(() {
_currentHighlightedIndex = closestIndex;
});
widget.onTimeSelected(_getTimeBasedOnIndex(closestIndex));
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification) {
_updateHighlightedIndex();
}
return true;
},
child: SizedBox(
width: widget.width,
height: 150,
child: Stack(
children: [
ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
physics: const OverscrollPhysics(),
itemCount: 49,
itemBuilder: (context, index) {
bool isHighlighted = index == _currentHighlightedIndex;
return Stack(
children: [
const SizedBox(
height: 10,
),
_buildBar(index, isHighlighted),
const SizedBox(height: 10),
Positioned(
bottom: 0,
child: Text(
_getTimeBasedOnIndex(index),
style: TextStyle(
color: isHighlighted ? Colors.purple : Colors.black,
fontSize: 18,
),
),
),
],
);
},
),
Positioned(
top: -20,
right: widget.width / 2 - 25,
child: const Icon(
Icons.arrow_drop_down,
color: Colors.purple,
size: 50,
),
),
],
),
),
);
}
}
class OverscrollPhysics extends ScrollPhysics {
final double maxOverscrollExtent = 70.0; // Allow overscroll but limit it.
const OverscrollPhysics({
ScrollPhysics? parent,
}) : super(parent: parent);
@override
OverscrollPhysics applyTo(ScrollPhysics? ancestor) {
return OverscrollPhysics(
parent: buildParent(ancestor),
);
}
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
return 0.0; // No boundary condition violation.
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
return offset; // Normal scrolling without friction.
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
if (position.pixels < position.minScrollExtent ||
position.pixels > position.maxScrollExtent) {
return ClampingScrollSimulation(
position: position.pixels,
velocity: velocity,
tolerance: Tolerance(
velocity: 1.0 / (0.05 * WidgetsBinding.instance.window.devicePixelRatio),
),
);
}
return super.createBallisticSimulation(position, velocity);
}
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
}
In your parent widget, use it like this:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Fixed Pin Time Picker")),
body: Column(
children: [
Expanded(
child: FixedPinTimePicker(
width: MediaQuery.of(context).size.width,
onTimeSelected: (time) {
print("Selected Time: $time");
},
),
),
// Other scrollable content
],
),
);
}
Explanation
Each bar's position is computed based on its index and the ScrollController's offset.
The closest bar to the anchor's center is highlighted in purple.
A ScrollNotification listener tracks the scroll position in real-time.
The _updateHighlightedIndex method calculates the closest bar and updates the index.
The onTimeSelected callback notifies the parent widget of the current selected time.