flutterflutter-animation

How Do I implement this Time Picker Slider Widget in Flutter?


Required UI

My Current Implementation

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),
              )),
        ],
      ),
    );
  }
}

Solution

  •     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

    1. Bar Highlighting:

    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.

    1. Scroll Listener:

    A ScrollNotification listener tracks the scroll position in real-time.

    The _updateHighlightedIndex method calculates the closest bar and updates the index.

    1. Time Callback:

    The onTimeSelected callback notifies the parent widget of the current selected time.

    1. the OverscrollPhysics(), is a custom scroll physics that allows overscrolling without bouncing back so that any time can be selected.