TECH

Flutter: Custom Cupertino Date Picker

Hey guys, do you ever required to build a Cupertino date picker, similar to the one shown in the image below? But got frustrated because CupertinoDate

Hitesh Verma May 28, 2023 · 6 min. read

Hey guys, do you ever required to build a Cupertino date picker, similar to the one shown in the image below? But got frustrated because CupertinoDatePicker widget does not bring much (any) customizations. :-(

This article will help you to create one of your own Cupertino date picker, with unlimited possibilities for customizations. :-)

Custom Cupertino Date Picker
Custom Cupertino Date Picker

Under the hood, the CupertinoDatePicker widget make use of CupertinoPicker widget, which brings some customizations.

We are also going to use the CupertinoPicker widget to achieve our goal!

Create a stateful widget for our custom date picker.

This widget will essentially be a wrapper to the CupertinoPicker widget. Declare all the parameters needed in a CupertinoPicker widget. (You can also limit yourself to the parameters you require.)

class CustomCupertinoDatePicker extends StatefulWidget {
  final double itemExtent;
  final Widget selectionOverlay;
  final double diameterRatio;
  final Color? backgroundColor;
  final double offAxisFraction;
  final bool useMaginifier;
  final double magnification;
  final double squeeze;
  final void Function(DateTime) onSelectedItemChanged;
  // Text style of selected item
  final TextStyle? selectedStyle;
  // Text style of unselected item  
  final TextStyle? unselectedStyle;
  // Text style of disabled item
  final TextStyle? disabledStyle;
  // Minimum selectable date  
  final DateTime? minDate;
  // Maximum selectable date
  final DateTime? maxDate;
  // Initially selected date
  final DateTime? selectedDate;
  const CustomCupertinoDatePicker({
    Key? key,
    required this.itemExtent,
    required this.onSelectedItemChanged,
    this.minDate,
    this.maxDate,
    this.selectedDate,
    this.selectedStyle,
    this.unselectedStyle,
    this.disabledStyle,
    this.backgroundColor,
    this.squeeze = 1.45,
    this.diameterRatio = 1.1,
    this.magnification = 1.0,
    this.offAxisFraction = 0.0,
    this.useMaginifier = false,
    this.selectionOverlay = const  
      CupertinoPickerDefaultSelectionOverlay(),
  }) : super(key: key);
  @override
  State<CustomCupertinoDatePicker> createState() =>
      _CustomCupertinoDatePickerState();
}
class _CustomCupertinoDatePickerState extends  
  State<CustomCupertinoDatePicker> {
  @override
  Widget build(BuildContext context) {}
}

In the code above, I’ve explained the some parameters in the comments. To know about all other parameters, please refer to CupertinoPicker doc.

Declare some properties

Let’s declare some properties in the _CustomCupertinoDatePickerState class which will be used in later steps.

late DateTime _minDate;
late DateTime _maxDate;
late DateTime _selectedDate;
late int _selectedDayIndex;
late int _selectedMonthIndex;
late int _selectedYearIndex;
late final FixedExtentScrollController _dayScrollController;
late final FixedExtentScrollController _monthScrollController;
late final FixedExtentScrollController _yearScrollController;
final _days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
final _months = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'Novemeber',
    'December',
];

You can change the strings of _months list, if you want to display some other texts for months. For example, [‘JAN’, ‘FEB’, ‘MAR’, …].

Initialize & validate dates & controllers

We’ve declared our controllers, now it’s time to initialize them.

@override
void initState() {
  super.initState();
  _validateDates();
  _dayScrollController = FixedExtentScrollController();
  _monthScrollController = FixedExtentScrollController();
  _yearScrollController = FixedExtentScrollController();
  _initDates();
}

Also, we need to validate the dates passed as arguments from the user.

void _validateDates() {
  if (widget.minDate != null && widget.maxDate != null) {
    assert(!widget.minDate!.isAfter(widget.maxDate!));
  }
  if (widget.minDate != null && widget.selectedDate != null) {
    assert(!widget.minDate!.isAfter(widget.selectedDate!));
  }
  if (widget.maxDate != null && widget.selectedDate != null) {
    assert(!widget.selectedDate!.isAfter(widget.maxDate!));
  }

Initialize dates in case, no dates are passed in the arguments.

void _initDates() {
  final currentDate = DateTime.now();
  _minDate = widget.minDate ?? DateTime(currentDate.year - 100);
  _maxDate = widget.maxDate ?? DateTime(currentDate.year + 100);
  if (widget.selectedDate != null) {
    _selectedDate = widget.selectedDate!;
  } else if (!currentDate.isBefore(_minDate) &&
    !currentDate.isAfter(_maxDate)) {
     _selectedDate = currentDate;
  } else {
     _selectedDate = _minDate;
  }
  _selectedDayIndex = _selectedDate.day - 1;
  _selectedMonthIndex = _selectedDate.month - 1;
  _selectedYearIndex = _selectedDate.year - _minDate.year;
  WidgetsBinding.instance.addPostFrameCallback((_) => {
    _scrollList(_dayScrollController, _selectedDayIndex),
    _scrollList(_monthScrollController, _selectedMonthIndex),
    _scrollList(_yearScrollController, _selectedYearIndex),
  });
}

Here, current date, minus 100 years from current date, and plus 100 years from current date is used as default values for _selectedDate, _minDate, and _maxDate respectively, in case no values are provided. But you are free to modify this as per your requirements.

Method to scroll our pickers to selected positions.

void _scrollList(FixedExtentScrollController controller, int index){
  controller.animateToItem(
    index,
    curve: Curves.easeIn,
    duration: const Duration(milliseconds: 300),
  );
}

Don’t forget to dispose of the controllers! It’s better to do it now.

@override
void dispose() {
  _dayScrollController.dispose();
  _monthScrollController.dispose();
  _yearScrollController.dispose();
  super.dispose();
}

Add utility methods

/// check if selected year is a leap year
bool _isLeapYear() {
  final year = _minDate.year + _selectedYearIndex;
  return year % 4 == 0 && 
    (year % 100 != 0 || (year % 100 == 0 && year % 400 == 0));
}
/// get number of days for the selected month
int _numberOfDays() {
  if (_selectedMonthIndex == 1) {
    _days[1] = _isLeapYear() ? 29 : 28;
  }
  return _days[_selectedMonthIndex];
}

Create an enum to distinguish among selectors, in the same or different file.
enum _SelectorType { day, month, year }

Update the selected date & indexes when picker selection changed

This method will check if the newly selected date is valid (within the min-max date range), if yes, then it will update the selected date & indexes, else it will scroll back Cupertino picker to the last position.

void _onSelectedItemChanged(int index, _SelectorType type) {
  DateTime temp;
  switch (type) {
    case _SelectorType.day:
      temp = DateTime(
        _minDate.year + _selectedYearIndex,
        _selectedMonthIndex + 1,
        index + 1,
      );
      break;
    case _SelectorType.month:
      temp = DateTime(
        _minDate.year + _selectedYearIndex,
        index + 1,
        _selectedDayIndex + 1,
      );
      break;
    case _SelectorType.year:
      temp = DateTime(
        _minDate.year + index,
        _selectedMonthIndex + 1,
        _selectedDayIndex + 1,
      );
      break;
  }
  
  // return if selected date is not the min - max date range
  // scroll selector back to the valid point
  if (temp.isBefore(_minDate) || temp.isAfter(_maxDate)) {
    switch (type) {
      case _SelectorType.day:
        _dayScrollController.jumpToItem(_selectedDayIndex);
        break;
      case _SelectorType.month:
        _monthScrollController.jumpToItem(_selectedMonthIndex);
        break;
      case _SelectorType.year:
        _yearScrollController.jumpToItem(_selectedYearIndex);
        break;
    }
    return;
  }
  // update selected date
  _selectedDate = temp;
  // adjust other selectors when one selctor is changed
  switch (type) {
    case _SelectorType.day:
      _selectedDayIndex = index;
      break;
    case _SelectorType.month:
      _selectedMonthIndex = index;
      // if month is changed to february & 
      // selected day is greater than 29, 
      // set the selected day to february 29 for leap year 
      // else to february 28
      if (_selectedMonthIndex == 1 && _selectedDayIndex > 27) {
        _selectedDayIndex = _isLeapYear() ? 28 : 27;
      }
      // if selected day is 31 but current selected month has only 
      // 30 days, set selected day to 30
      if (_selectedDayIndex == 30 && _days[_selectedMonthIndex] ==    
        30) {
        _selectedDayIndex = 29;
      }
      break;
    case _SelectorType.year:
      _selectedYearIndex = index;
      // if selected month is february & selected day is 29
      // But now year is changed to non-leap year
      // set the day to february 28
      if (!_isLeapYear() &&
        _selectedMonthIndex == 1 &&
        _selectedDayIndex == 28) {
         _selectedDayIndex = 27;
      }
      break;
    }
  setState(() {});
  widget.onSelectedItemChanged(_selectedDate);
}

Check for disabled values

Check if a picker item is disabled as it exists outside the min-max date range.

/// check if the given day, month or year index is disabled
bool _isDisabled(int index, _SelectorType type) {
  DateTime temp;
  switch (type) {
    case _SelectorType.day:
      temp = DateTime(
        _minDate.year + _selectedYearIndex,
        _selectedMonthIndex + 1,
        index + 1,
      );
      break;
    case _SelectorType.month:
      temp = DateTime(
        _minDate.year + _selectedYearIndex,
        index + 1,
        _selectedDayIndex + 1,
      );
      break;
    case _SelectorType.year:
      temp = DateTime(
        _minDate.year + index,
        _selectedMonthIndex + 1,
        _selectedDayIndex + 1,
      );
      break;
  }
  return temp.isAfter(_maxDate) || temp.isBefore(_minDate);
}

Create a common CupertinoPicker widget.

In this step, we’ll create a method for the common CupertinoPicker widget, which will be later used for days, months & years picker.

Widget _selector({
  required List<dynamic> values,
  required int selectedValueIndex,
  required bool Function(int) isDisabled,
  required void Function(int) onSelectedItemChanged,
  required FixedExtentScrollController scrollController,
}) {
  return CupertinoPicker.builder(
    childCount: values.length,
    squeeze: widget.squeeze,
    itemExtent: widget.itemExtent,
    scrollController: scrollController,
    useMagnifier: widget.useMaginifier,
    diameterRatio: widget.diameterRatio,
    magnification: widget.magnification,
    backgroundColor: widget.backgroundColor,
    offAxisFraction: widget.offAxisFraction,
    selectionOverlay: widget.selectionOverlay,
    onSelectedItemChanged: onSelectedItemChanged,
    itemBuilder: (context, index) => Container(
      height: widget.itemExtent,
      alignment: Alignment.center,
      child: Text(
        '${values[index]}',
        style: index == selectedValueIndex
          ? widget.selectedStyle
          : isDisabled(index)
            ? widget.disabledStyle
            : widget.unselectedStyle,
      ),
    ),
  );
}

You can see our “_selector” method has some parameters. Their purpose is: values: list of items to be displayed in the CupertinoPicker, for example, list of months [“Jan”, “Feb”, …]. Must be able to interpolate in the string ‘${values[index]}’ (for now) or you can tweak the code accordingly. selectedValueIndex: index of selected item from “values”.
isDisabled: callback function to determine if an item is disabled, in case it is out of min-max date range.
onSelectedItemChanged: callback function when CupertinoPicker selection changes.
scrollController: scroll controller for CupertinoPicker.

Add picker for days

Widget _daySelector() {
  return _selector(
    values: List.generate(_numberOfDays(), (index) => index + 1),
    selectedValueIndex: _selectedDayIndex,
    scrollController: _dayScrollController,
    isDisabled: (index) => _isDisabled(index, _SelectorType.day),
    onSelectedItemChanged: (v) => _onSelectedItemChanged(
      v,
      _SelectorType.day,
    ),
  );
}

Add picker for months

Widget _monthSelector() {
  return _selector(
    values: _months,
    selectedValueIndex: _selectedMonthIndex,
    scrollController: _monthScrollController,
    isDisabled: (index) => _isDisabled(index, _SelectorType.month),
    onSelectedItemChanged: (v) => _onSelectedItemChanged(
      v,
      _SelectorType.month,
    ),
  );
}

Add picker for years

Widget _yearSelector() {
  return _selector(
    values: List.generate(
      _maxDate.year - _minDate.year + 1,
      (index) => _minDate.year + index,
    ),
    selectedValueIndex: _selectedYearIndex,
    scrollController: _yearScrollController,
    isDisabled: (index) => _isDisabled(index, _SelectorType.year),
    onSelectedItemChanged: (v) => _onSelectedItemChanged(
      v,
      _SelectorType.year,
    ),
  );
}

Combine all three pickers for days, months & years

@override
Widget build(BuildContext context) {
  return Row(
    children: [
      Expanded(child: _monthSelector()),
      Expanded(child: _daySelector()),
      Expanded(child: _yearSelector()),
    ],
  );
}

And that’s it! Now we have our own custom CupertinoDatePicker.

You can now use custom CupertinoDatePicker like this.

class CustomCupertinoPickerApp extends StatefulWidget {
  const CustomCupertinoPickerApp({Key? key}) : super(key: key);
  @override
  State<CustomCupertinoPickerApp> createState() =>
      _CustomCupertinoPickerAppState();
}
class _CustomCupertinoPickerAppState extends 
  State<CustomCupertinoPickerApp> {
  late final DateTime _minDate;
  late final DateTime _maxDate;
  late DateTime _selecteDate;
  @override
  void initState() {
    super.initState();
    final currentDate = DateTime.now();
    _minDate = DateTime(
      currentDate.year - 100,
      currentDate.month,
      currentDate.day,
    );
    _maxDate = DateTime(
      currentDate.year - 18,
      currentDate.month,
      currentDate.day,
    );
    _selecteDate = _maxDate;
  }
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: SizedBox(
            height: 300,
            child: CustomCupertinoDatePicker(
              itemExtent: 50,
              minDate: _minDate,
              maxDate: _maxDate,
              selectedDate: _selecteDate,
              selectionOverlay: Container(
                width: double.infinity,
                height: 50,
                decoration: const BoxDecoration(
                  border: Border.symmetric(
                   horizontal:BorderSide(color:Colors.grey,width:1),
                  ),
                ),
              ),
              selectedStyle: const TextStyle(
                color: Colors.black,
                fontWeight: FontWeight.w600,
                fontSize: 24,
              ),
              unselectedStyle: TextStyle(
                color: Colors.grey[800],
                fontSize: 18,
              ),
              disabledStyle: TextStyle(
                color: Colors.grey[400],
                fontSize: 18,
              ),
              onSelectedItemChanged: (date) => _selecteDate = date,
            ),
          ),
        ),
      ),
    );
  }
}

Thank you for reading this article. Hope it helped you. :-)

Read next

Customized Calendar in Flutter

Hey, have you ever wanted a date-picker in your app but not in a dialog box? And you tried all the dart packages out there but cannot change their UIs

Hitesh Verma May 28, 2023 · 9 min read

Flutter | Highlight searched text in results

Hitesh Verma in TECH
May 12, 2023 · 4 min read

Flutter Theming | The Right Way

Hitesh Verma in TECH
May 12, 2023 · 5 min read