// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: implementation_imports import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/src/cupertino/picker.dart'; // Values derived from https://developer.apple.com/design/resources/ and on iOS // simulators with "Debug View Hierarchy". const double _kItemExtent = 56.0; // From the picker's intrinsic content size constraint. const double _kPickerWidth = 380.0; const bool _kUseMagnifier = true; // const double _kMagnification = 2.35 / 2.1; const double _kMagnification = 1.06; const double _kDatePickerPadSize = 12.0; // The density of a date picker is different from a generic picker. // Eyeballed from iOS. const double _kSqueeze = 1.25; const TextStyle _kDefaultPickerTextStyle = TextStyle( letterSpacing: -0.83, ); TextStyle _themeTextStyle(BuildContext context, {bool isValid = true}) { return isValid ? const TextStyle(color: Colors.black, fontSize: 24) : TextStyle(color: Colors.grey[700], fontSize: 22); } void _animateColumnControllerToItem( FixedExtentScrollController controller, int targetItem) { controller.animateToItem( targetItem, curve: Curves.easeInOut, duration: const Duration(milliseconds: 200), ); } const Widget _startSelectionOverlay = CupertinoPickerDefaultSelectionOverlay( capEndEdge: false, background: Colors.transparent, ); const Widget _centerSelectionOverlay = CupertinoPickerDefaultSelectionOverlay( capStartEdge: false, capEndEdge: false, background: Colors.transparent, ); const Widget _endSelectionOverlay = CupertinoPickerDefaultSelectionOverlay( capStartEdge: false, background: Colors.transparent, ); // Lays out the date picker based on how much space each single column needs. // // Each column is a child of this delegate, indexed from 0 to number of columns - 1. // Each column will be padded horizontally by 12.0 both left and right. // // The picker will be placed in the center, and the leftmost and rightmost // column will be extended equally to the remaining width. class _DatePickerLayoutDelegate extends MultiChildLayoutDelegate { _DatePickerLayoutDelegate({ required this.columnWidths, required this.textDirectionFactor, required this.maxWidth, }); // The list containing widths of all columns. final List columnWidths; // textDirectionFactor is 1 if text is written left to right, and -1 if right to left. final int textDirectionFactor; // The max width the children should reach to avoid bending outwards. final double maxWidth; @override void performLayout(Size size) { double remainingWidth = maxWidth < size.width ? maxWidth : size.width; double currentHorizontalOffset = (size.width - remainingWidth) / 2; for (int i = 0; i < columnWidths.length; i++) { remainingWidth -= columnWidths[i] + _kDatePickerPadSize * 2; } for (int i = 0; i < columnWidths.length; i++) { final int index = textDirectionFactor == 1 ? i : columnWidths.length - i - 1; double childWidth = columnWidths[index] + _kDatePickerPadSize * 2; if (index == 0 || index == columnWidths.length - 1) { childWidth += remainingWidth / 2; } // We can't actually assert here because it would break things badly for // semantics, which will expect that we laid things out here. assert(() { if (childWidth < 0) { FlutterError.reportError( FlutterErrorDetails( exception: FlutterError( 'Insufficient horizontal space to render the ' 'CupertinoDatePicker because the parent is too narrow at ' '${size.width}px.\n' 'An additional ${-remainingWidth}px is needed to avoid ' 'overlapping columns.', ), ), ); } return true; }()); layoutChild(index, BoxConstraints.tight(Size(math.max(0.0, childWidth), size.height))); positionChild(index, Offset(currentHorizontalOffset, 0.0)); currentHorizontalOffset += childWidth; } } @override bool shouldRelayout(_DatePickerLayoutDelegate oldDelegate) { return columnWidths != oldDelegate.columnWidths || textDirectionFactor != oldDelegate.textDirectionFactor; } } /// Different display modes of [CupertinoDatePicker]. /// /// See also: /// /// * [CupertinoDatePicker], the class that implements different display modes /// of the iOS-style date picker. /// * [CupertinoPicker], the class that implements a content agnostic spinner UI. enum CupertinoDatePickerMode { /// Mode that shows the date in hour, minute, and (optional) an AM/PM designation. /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. /// Column order is subject to internationalization. /// /// Example: ` 4 | 14 | PM `. time, /// Mode that shows the date in month, day of month, and year. /// Name of month is spelled in full. /// Column order is subject to internationalization. /// /// Example: ` July | 13 | 2012 `. date, /// Mode that shows the date as day of the week, month, day of month and /// the time in hour, minute, and (optional) an AM/PM designation. /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. /// Column order is subject to internationalization. /// /// Example: ` Fri Jul 13 | 4 | 14 | PM ` dateAndTime, } // Different types of column in CupertinoDatePicker. enum _PickerColumnType { // Day of month column in date mode. dayOfMonth, // Month column in date mode. month, // Year column in date mode. year, } /// A date picker widget in iOS style. /// /// There are several modes of the date picker listed in [CupertinoDatePickerMode]. /// /// The class will display its children as consecutive columns. Its children /// order is based on internationalization, or the [dateOrder] property if specified. /// /// Example of the picker in date mode: /// /// * US-English: `| July | 13 | 2012 |` /// * Vietnamese: `| 13 | Tháng 7 | 2012 |` /// /// Can be used with [showCupertinoModalPopup] to display the picker modally at /// the bottom of the screen. /// /// Sizes itself to its parent and may not render correctly if not given the /// full screen width. Content texts are shown with /// [CupertinoTextThemeData.dateTimePickerTextStyle]. /// /// {@tool dartpad} /// This sample shows how to implement CupertinoDatePicker with different picker modes. /// We can provide initial dateTime value for the picker to display. When user changes /// the drag the date or time wheels, the picker will call onDateTimeChanged callback. /// /// CupertinoDatePicker can be displayed directly on a screen or in a popup. /// /// ** See code in examples/api/lib/cupertino/date_picker/cupertino_date_picker.0.dart ** /// {@end-tool} /// /// See also: /// /// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. /// * [CupertinoPicker], the class that implements a content agnostic spinner UI. /// * class VCustomCupertinoDatePicker extends StatefulWidget { /// Constructs an iOS style date picker. /// /// [mode] is one of the mode listed in [CupertinoDatePickerMode] and defaults /// to [CupertinoDatePickerMode.dateAndTime]. /// /// [onDateTimeChanged] is the callback called when the selected date or time /// changes and must not be null. When in [CupertinoDatePickerMode.time] mode, /// the year, month and day will be the same as [initialDateTime]. When in /// [CupertinoDatePickerMode.date] mode, this callback will always report the /// start time of the currently selected day. /// /// [initialDateTime] is the initial date time of the picker. Defaults to the /// present date and time and must not be null. The present must conform to /// the intervals set in [minimumDate], [maximumDate], [minimumYear], and /// [maximumYear]. /// /// [minimumDate] is the minimum selectable [DateTime] of the picker. When set /// to null, the picker does not limit the minimum [DateTime] the user can pick. /// In [CupertinoDatePickerMode.time] mode, [minimumDate] should typically be /// on the same date as [initialDateTime], as the picker will not limit the /// minimum time the user can pick if it's set to a date earlier than that. /// /// [maximumDate] is the maximum selectable [DateTime] of the picker. When set /// to null, the picker does not limit the maximum [DateTime] the user can pick. /// In [CupertinoDatePickerMode.time] mode, [maximumDate] should typically be /// on the same date as [initialDateTime], as the picker will not limit the /// maximum time the user can pick if it's set to a date later than that. /// /// [minimumYear] is the minimum year that the picker can be scrolled to in /// [CupertinoDatePickerMode.date] mode. Defaults to 1 and must not be null. /// /// [maximumYear] is the maximum year that the picker can be scrolled to in /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. /// /// [minuteInterval] is the granularity of the minute spinner. Must be a /// positive integer factor of 60. /// /// [use24hFormat] decides whether 24 hour format is used. Defaults to false. /// /// [dateOrder] determines the order of the columns inside [CupertinoDatePicker] in date mode. /// Defaults to the locale's default date format/order. VCustomCupertinoDatePicker({ super.key, required this.onDateTimeChanged, DateTime? initialDateTime, this.minimumDate, this.maximumDate, this.minimumYear = 1, this.maximumYear, this.minuteInterval = 1, this.backgroundColor, }) : initialDateTime = initialDateTime ?? DateTime.now(), assert( minuteInterval > 0 && 60 % minuteInterval == 0, 'minute interval is not a positive integer factor of 60', ) { assert( this.initialDateTime.minute % minuteInterval == 0, 'initial minute is not divisible by minute interval', ); } /// The initial date and/or time of the picker. Defaults to the present date /// and time and must not be null. The present must conform to the intervals /// set in [minimumDate], [maximumDate], [minimumYear], and [maximumYear]. /// /// Changing this value after the initial build will not affect the currently /// selected date time. final DateTime initialDateTime; /// The minimum selectable date that the picker can settle on. /// /// When non-null, the user can still scroll the picker to [DateTime]s earlier /// than [minimumDate], but the [onDateTimeChanged] will not be called on /// these [DateTime]s. Once let go, the picker will scroll back to [minimumDate]. /// /// In [CupertinoDatePickerMode.time] mode, a time becomes unselectable if the /// [DateTime] produced by combining that particular time and the date part of /// [initialDateTime] is earlier than [minimumDate]. So typically [minimumDate] /// needs to be set to a [DateTime] that is on the same date as [initialDateTime]. /// /// Defaults to null. When set to null, the picker does not impose a limit on /// the earliest [DateTime] the user can select. final DateTime? minimumDate; /// The maximum selectable date that the picker can settle on. /// /// When non-null, the user can still scroll the picker to [DateTime]s later /// than [maximumDate], but the [onDateTimeChanged] will not be called on /// these [DateTime]s. Once let go, the picker will scroll back to [maximumDate]. /// /// In [CupertinoDatePickerMode.time] mode, a time becomes unselectable if the /// [DateTime] produced by combining that particular time and the date part of /// [initialDateTime] is later than [maximumDate]. So typically [maximumDate] /// needs to be set to a [DateTime] that is on the same date as [initialDateTime]. /// /// Defaults to null. When set to null, the picker does not impose a limit on /// the latest [DateTime] the user can select. final DateTime? maximumDate; /// Minimum year that the picker can be scrolled to in /// [CupertinoDatePickerMode.date] mode. Defaults to 1 and must not be null. final int minimumYear; /// Maximum year that the picker can be scrolled to in /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. final int? maximumYear; /// The granularity of the minutes spinner, if it is shown in the current mode. /// Must be an integer factor of 60. final int minuteInterval; /// Callback called when the selected date and/or time changes. If the new /// selected [DateTime] is not valid, or is not in the [minimumDate] through /// [maximumDate] range, this callback will not be called. /// /// Must not be null. final ValueChanged onDateTimeChanged; /// Background color of date picker. /// /// Defaults to null, which disables background painting entirely. final Color? backgroundColor; @override State createState() => // ignore: no_logic_in_create_state _CupertinoDatePickerDateState(); // Estimate the minimum width that each column needs to layout its content. static double _getColumnWidth( _PickerColumnType columnType, BuildContext context) { switch (columnType) { case _PickerColumnType.year: return 132; case _PickerColumnType.month: return 90; case _PickerColumnType.dayOfMonth: return 110; default: return 0; } } } typedef _ColumnBuilder = Widget Function(double offAxisFraction, TransitionBuilder itemPositioningBuilder, Widget selectionOverlay); class _CupertinoDatePickerDateState extends State { late int textDirectionFactor; // Alignment based on text direction. The variable name is self descriptive, // however, when text direction is rtl, alignment is reversed. late Alignment alignCenterLeft; late Alignment alignCenterRight; // The currently selected values of the picker. late int selectedDay; late int selectedMonth; late int selectedYear; // The controller of the day picker. There are cases where the selected value // of the picker is invalid (e.g. February 30th 2018), and this dayController // is responsible for jumping to a valid value. late FixedExtentScrollController dayController; late FixedExtentScrollController monthController; late FixedExtentScrollController yearController; bool isDayPickerScrolling = false; bool isMonthPickerScrolling = false; bool isYearPickerScrolling = false; bool get isScrolling => isDayPickerScrolling || isMonthPickerScrolling || isYearPickerScrolling; // Estimated width of columns. Map estimatedColumnWidths = {}; @override void initState() { super.initState(); selectedDay = widget.initialDateTime.day; selectedMonth = widget.initialDateTime.month; selectedYear = widget.initialDateTime.year; dayController = FixedExtentScrollController(initialItem: selectedDay - 1); monthController = FixedExtentScrollController(initialItem: selectedMonth - 1); yearController = FixedExtentScrollController(initialItem: selectedYear); PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); } void _handleSystemFontsChange() { setState(() { // System fonts change might cause the text layout width to change. _refreshEstimatedColumnWidths(); }); } @override void dispose() { dayController.dispose(); monthController.dispose(); yearController.dispose(); PaintingBinding.instance.systemFonts .removeListener(_handleSystemFontsChange); super.dispose(); } @override void didChangeDependencies() { super.didChangeDependencies(); textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; _refreshEstimatedColumnWidths(); } void _refreshEstimatedColumnWidths() { estimatedColumnWidths[_PickerColumnType.dayOfMonth.index] = VCustomCupertinoDatePicker._getColumnWidth( _PickerColumnType.dayOfMonth, context); estimatedColumnWidths[_PickerColumnType.month.index] = VCustomCupertinoDatePicker._getColumnWidth( _PickerColumnType.month, context); estimatedColumnWidths[_PickerColumnType.year.index] = VCustomCupertinoDatePicker._getColumnWidth( _PickerColumnType.year, context); } // The DateTime of the last day of a given month in a given year. // Let `DateTime` handle the year/month overflow. DateTime _lastDayInMonth(int year, int month) => DateTime(year, month + 1, 0); Widget _buildDayPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder, Widget selectionOverlay) { final int daysInCurrentMonth = _lastDayInMonth(selectedYear, selectedMonth).day; return NotificationListener( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isDayPickerScrolling = true; } else if (notification is ScrollEndNotification) { isDayPickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker( scrollController: dayController, // offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { selectedDay = index + 1; if (_isCurrentDateValid) { widget.onDateTimeChanged( DateTime(selectedYear, selectedMonth, selectedDay)); } }, looping: true, selectionOverlay: selectionOverlay, children: List.generate(31, (int index) { final int day = index + 1; return itemPositioningBuilder( context, Text( "$day日", style: _themeTextStyle(context, isValid: day <= daysInCurrentMonth), ), ); }), ), ); } Widget _buildMonthPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder, Widget selectionOverlay) { return NotificationListener( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isMonthPickerScrolling = true; } else if (notification is ScrollEndNotification) { isMonthPickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker( scrollController: monthController, // offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { selectedMonth = index + 1; if (_isCurrentDateValid) { widget.onDateTimeChanged( DateTime(selectedYear, selectedMonth, selectedDay)); } }, looping: true, selectionOverlay: selectionOverlay, children: List.generate(12, (int index) { final int month = index + 1; final bool isInvalidMonth = (widget.minimumDate?.year == selectedYear && widget.minimumDate!.month > month) || (widget.maximumDate?.year == selectedYear && widget.maximumDate!.month < month); return itemPositioningBuilder( context, Text( "$month月", style: _themeTextStyle(context, isValid: !isInvalidMonth), ), ); }), ), ); } Widget _buildYearPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder, Widget selectionOverlay) { return NotificationListener( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isYearPickerScrolling = true; } else if (notification is ScrollEndNotification) { isYearPickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker.builder( scrollController: yearController, itemExtent: _kItemExtent, // offAxisFraction: offAxisFraction, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, onSelectedItemChanged: (int index) { selectedYear = index; if (_isCurrentDateValid) { widget.onDateTimeChanged( DateTime(selectedYear, selectedMonth, selectedDay)); } }, itemBuilder: (BuildContext context, int year) { if (year < widget.minimumYear) { return null; } if (widget.maximumYear != null && year > widget.maximumYear!) { return null; } final bool isValidYear = (widget.minimumDate == null || widget.minimumDate!.year <= year) && (widget.maximumDate == null || widget.maximumDate!.year >= year); return itemPositioningBuilder( context, Text( "$year年", style: _themeTextStyle(context, isValid: isValidYear), ), ); }, selectionOverlay: selectionOverlay, ), ); } bool get _isCurrentDateValid { // The current date selection represents a range [minSelectedData, maxSelectDate]. final DateTime minSelectedDate = DateTime(selectedYear, selectedMonth, selectedDay); final DateTime maxSelectedDate = DateTime(selectedYear, selectedMonth, selectedDay + 1); final bool minCheck = widget.minimumDate?.isBefore(maxSelectedDate) ?? true; final bool maxCheck = widget.maximumDate?.isBefore(minSelectedDate) ?? false; return minCheck && !maxCheck && minSelectedDate.day == selectedDay; } // One or more pickers have just stopped scrolling. void _pickerDidStopScrolling() { // Call setState to update the greyed out days/months/years, as the currently // selected year/month may have changed. setState(() {}); if (isScrolling) { return; } // Whenever scrolling lands on an invalid entry, the picker // automatically scrolls to a valid one. final DateTime minSelectDate = DateTime(selectedYear, selectedMonth, selectedDay); final DateTime maxSelectDate = DateTime(selectedYear, selectedMonth, selectedDay + 1); final bool minCheck = widget.minimumDate?.isBefore(maxSelectDate) ?? true; final bool maxCheck = widget.maximumDate?.isBefore(minSelectDate) ?? false; if (!minCheck || maxCheck) { // We have minCheck === !maxCheck. final DateTime targetDate = minCheck ? widget.maximumDate! : widget.minimumDate!; _scrollToDate(targetDate); return; } // Some months have less days (e.g. February). Go to the last day of that month // if the selectedDay exceeds the maximum. if (minSelectDate.day != selectedDay) { final DateTime lastDay = _lastDayInMonth(selectedYear, selectedMonth); _scrollToDate(lastDay); } } void _scrollToDate(DateTime newDate) { SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { if (selectedYear != newDate.year) { _animateColumnControllerToItem(yearController, newDate.year); } if (selectedMonth != newDate.month) { _animateColumnControllerToItem(monthController, newDate.month - 1); } if (selectedDay != newDate.day) { _animateColumnControllerToItem(dayController, newDate.day - 1); } }); } @override Widget build(BuildContext context) { final List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[ _buildYearPicker, _buildMonthPicker, _buildDayPicker, ]; final List columnWidths = [ estimatedColumnWidths[_PickerColumnType.year.index]!, estimatedColumnWidths[_PickerColumnType.month.index]!, estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!, ]; final List pickers = []; double totalColumnWidths = 4 * _kDatePickerPadSize; for (int i = 0; i < columnWidths.length; i++) { final double offAxisFraction = (i - 1) * 0.3 * textDirectionFactor; EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize); if (textDirectionFactor == -1) { padding = const EdgeInsets.only(left: _kDatePickerPadSize); } Widget selectionOverlay = _centerSelectionOverlay; if (i == 0) { selectionOverlay = _startSelectionOverlay; } else if (i == columnWidths.length - 1) { selectionOverlay = _endSelectionOverlay; } totalColumnWidths += columnWidths[i] + (2 * _kDatePickerPadSize); pickers.add(LayoutId( id: i, child: pickerBuilders[i]( offAxisFraction, (BuildContext context, Widget? child) { return Container( alignment: i == columnWidths.length - 1 ? alignCenterLeft : alignCenterRight, padding: i == 0 ? null : padding, child: Container( alignment: i == 0 ? alignCenterLeft : alignCenterRight, width: columnWidths[i] + _kDatePickerPadSize, child: child, ), ); }, selectionOverlay, ), )); } final double maxPickerWidth = totalColumnWidths > _kPickerWidth ? totalColumnWidths : _kPickerWidth; return MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: DefaultTextStyle.merge( style: _kDefaultPickerTextStyle, child: CustomMultiChildLayout( delegate: _DatePickerLayoutDelegate( columnWidths: columnWidths, textDirectionFactor: textDirectionFactor, maxWidth: maxPickerWidth, ), children: pickers, ), ), ); } }