// 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. import 'dart:math' as math; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'localizations.dart'; import 'picker.dart'; import 'theme.dart'; // Values derived from https://developer.apple.com/design/resources/ and on iOS // simulators with "Debug View Hierarchy". const double _kItemExtent = 32.0; // From the picker's intrinsic content size constraint. const double _kPickerWidth = 320.0; const double _kPickerHeight = 216.0; const bool _kUseMagnifier = true; const double _kMagnification = 2.35/2.1; 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, ); // Half of the horizontal padding value between the timer picker's columns. const double _kTimerPickerHalfColumnPadding = 2; // The horizontal padding between the timer picker's number label and its // corresponding unit label. const double _kTimerPickerLabelPadSize = 4.5; const double _kTimerPickerLabelFontSize = 17.0; // The width of each colmn of the countdown time picker. const double _kTimerPickerColumnIntrinsicWidth = 106; // Unfortunately turning on magnification for the timer picker messes up the label // alignment. So we'll have to hard code the font size and turn magnification off // for now. const double _kTimerPickerNumberLabelFontSize = 23; TextStyle _themeTextStyle(BuildContext context, { bool isValid = true }) { final TextStyle style = CupertinoTheme.of(context).textTheme.dateTimePickerTextStyle; return isValid ? style : style.copyWith(color: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context)); } void _animateColumnControllerToItem(FixedExtentScrollController controller, int targetItem) { controller.animateToItem( targetItem, curve: Curves.easeInOut, duration: const Duration(milliseconds: 200), ); } // 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, }) : assert(columnWidths != null), assert(textDirectionFactor != null); // The list containing widths of all columns. final List<double> columnWidths; // textDirectionFactor is 1 if text is written left to right, and -1 if right to left. final int textDirectionFactor; @override void performLayout(Size size) { double remainingWidth = size.width; for (int i = 0; i < columnWidths.length; i++) remainingWidth -= columnWidths[i] + _kDatePickerPadSize * 2; double currentHorizontalOffset = 0.0; 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, // Medium date column in dateAndTime mode. date, // Hour column in time and dateAndTime mode. hour, // minute column in time and dateAndTime mode. minute, // AM/PM column in time and dateAndTime mode. dayPeriod, } /// 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. /// /// 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]. /// /// See also: /// /// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. /// * [CupertinoPicker], the class that implements a content agnostic spinner UI. class CupertinoDatePicker 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 [DateTime] that the picker can be scrolled to. /// Null if there's no limit. In [CupertinoDatePickerMode.time] mode, if the /// date part of [initialDateTime] is after that of the [minimumDate], [minimumDate] /// has no effect. /// /// [maximumDate] is the maximum [DateTime] that the picker can be scrolled to. /// Null if there's no limit. In [CupertinoDatePickerMode.time] mode, if the /// date part of [initialDateTime] is before that of the [maximumDate], [maximumDate] /// has no effect. /// /// [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. CupertinoDatePicker({ Key key, this.mode = CupertinoDatePickerMode.dateAndTime, @required this.onDateTimeChanged, DateTime initialDateTime, this.minimumDate, this.maximumDate, this.minimumYear = 1, this.maximumYear, this.minuteInterval = 1, this.use24hFormat = false, this.backgroundColor, }) : initialDateTime = initialDateTime ?? DateTime.now(), assert(mode != null), assert(onDateTimeChanged != null), assert(minimumYear != null), assert( minuteInterval > 0 && 60 % minuteInterval == 0, 'minute interval is not a positive integer factor of 60', ), super(key: key) { assert(this.initialDateTime != null); assert( mode != CupertinoDatePickerMode.dateAndTime || minimumDate == null || !this.initialDateTime.isBefore(minimumDate), 'initial date is before minimum date', ); assert( mode != CupertinoDatePickerMode.dateAndTime || maximumDate == null || !this.initialDateTime.isAfter(maximumDate), 'initial date is after maximum date', ); assert( mode != CupertinoDatePickerMode.date || (minimumYear >= 1 && this.initialDateTime.year >= minimumYear), 'initial year is not greater than minimum year, or mininum year is not positive', ); assert( mode != CupertinoDatePickerMode.date || maximumYear == null || this.initialDateTime.year <= maximumYear, 'initial year is not smaller than maximum year', ); assert( mode != CupertinoDatePickerMode.date || minimumDate == null || !minimumDate.isAfter(this.initialDateTime), 'initial date ${this.initialDateTime} is not greater than or euqal to minimumDate $minimumDate', ); assert( mode != CupertinoDatePickerMode.date || maximumDate == null || !maximumDate.isBefore(this.initialDateTime), 'initial date ${this.initialDateTime} is not less than or euqal to maximumDate $maximumDate', ); assert( this.initialDateTime.minute % minuteInterval == 0, 'initial minute is not divisible by minute interval', ); } /// The mode of the date picker as one of [CupertinoDatePickerMode]. /// Defaults to [CupertinoDatePickerMode.dateAndTime]. Cannot be null and /// value cannot change after initial build. final CupertinoDatePickerMode mode; /// 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; /// Minimum date that the picker can be scrolled to in [CupertinoDatePickerMode.date] /// and [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. final DateTime minimumDate; /// Maximum date that the picker can be scrolled to in [CupertinoDatePickerMode.date] /// and [CupertinoDatePickerMode.dateAndTime] mode. Null if there's no limit. 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; /// Whether to use 24 hour format. Defaults to false. final bool use24hFormat; /// 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<DateTime> onDateTimeChanged; /// Background color of date picker. /// /// Defaults to null, which disables background painting entirely. final Color backgroundColor; @override State<StatefulWidget> createState() { // The `time` mode and `dateAndTime` mode of the picker share the time // columns, so they are placed together to one state. // The `date` mode has different children and is implemented in a different // state. switch (mode) { case CupertinoDatePickerMode.time: case CupertinoDatePickerMode.dateAndTime: return _CupertinoDatePickerDateTimeState(); case CupertinoDatePickerMode.date: return _CupertinoDatePickerDateState(); } assert(false); return _CupertinoDatePickerDateTimeState(); } // Estimate the minimum width that each column needs to layout its content. static double _getColumnWidth( _PickerColumnType columnType, CupertinoLocalizations localizations, BuildContext context, ) { String longestText = ''; switch (columnType) { case _PickerColumnType.date: // Measuring the length of all possible date is impossible, so here // just some dates are measured. for (int i = 1; i <= 12; i++) { // An arbitrary date. final String date = localizations.datePickerMediumDate(DateTime(2018, i, 25)); if (longestText.length < date.length) longestText = date; } break; case _PickerColumnType.hour: for (int i = 0; i < 24; i++) { final String hour = localizations.datePickerHour(i); if (longestText.length < hour.length) longestText = hour; } break; case _PickerColumnType.minute: for (int i = 0; i < 60; i++) { final String minute = localizations.datePickerMinute(i); if (longestText.length < minute.length) longestText = minute; } break; case _PickerColumnType.dayPeriod: longestText = localizations.anteMeridiemAbbreviation.length > localizations.postMeridiemAbbreviation.length ? localizations.anteMeridiemAbbreviation : localizations.postMeridiemAbbreviation; break; case _PickerColumnType.dayOfMonth: for (int i = 1; i <=31; i++) { final String dayOfMonth = localizations.datePickerDayOfMonth(i); if (longestText.length < dayOfMonth.length) longestText = dayOfMonth; } break; case _PickerColumnType.month: for (int i = 1; i <=12; i++) { final String month = localizations.datePickerMonth(i); if (longestText.length < month.length) longestText = month; } break; case _PickerColumnType.year: longestText = localizations.datePickerYear(2018); break; } assert(longestText != '', 'column type is not appropriate'); final TextPainter painter = TextPainter( text: TextSpan( style: _themeTextStyle(context), text: longestText, ), textDirection: Directionality.of(context), ); // This operation is expensive and should be avoided. It is called here only // because there's no other way to get the information we want without // laying out the text. painter.layout(); return painter.maxIntrinsicWidth; } } typedef _ColumnBuilder = Widget Function(double offAxisFraction, TransitionBuilder itemPositioningBuilder); class _CupertinoDatePickerDateTimeState extends State<CupertinoDatePicker> { // Fraction of the farthest column's vanishing point vs its width. Eyeballed // vs iOS. static const double _kMaximumOffAxisFraction = 0.45; int textDirectionFactor; CupertinoLocalizations localizations; // Alignment based on text direction. The variable name is self descriptive, // however, when text direction is rtl, alignment is reversed. Alignment alignCenterLeft; Alignment alignCenterRight; // Read this out when the state is initially created. Changes in initialDateTime // in the widget after first build is ignored. DateTime initialDateTime; // The difference in days between the initial date and the currently selected date. // 0 if the current mode does not involve a date. int get selectedDayFromInitial { switch (widget.mode) { case CupertinoDatePickerMode.dateAndTime: return dateController.hasClients ? dateController.selectedItem : 0; case CupertinoDatePickerMode.time: return 0; case CupertinoDatePickerMode.date: break; } assert( false, '$runtimeType is only meant for dateAndTime mode or time mode', ); return 0; } // The controller of the date column. FixedExtentScrollController dateController; // The current selection of the hour picker. Values range from 0 to 23. int get selectedHour => _selectedHour(selectedAmPm, _selectedHourIndex); int get _selectedHourIndex => hourController.hasClients ? hourController.selectedItem % 24 : initialDateTime.hour; // Calculates the selected hour given the selected indices of the hour picker // and the meridiem picker. int _selectedHour(int selectedAmPm, int selectedHour) { return _isHourRegionFlipped(selectedAmPm) ? (selectedHour + 12) % 24 : selectedHour; } // The controller of the hour column. FixedExtentScrollController hourController; // The current selection of the minute picker. Values range from 0 to 59. int get selectedMinute { return minuteController.hasClients ? minuteController.selectedItem * widget.minuteInterval % 60 : initialDateTime.minute; } // The controller of the minute column. FixedExtentScrollController minuteController; // Whether the current meridiem selection is AM or PM. // // We can't use the selectedItem of meridiemController as the source of truth // because the meridiem picker can be scrolled **animatedly** by the hour picker // (e.g. if you scroll from 12 to 1 in 12h format), but the meridiem change // should take effect immediately, **before** the animation finishes. int selectedAmPm; // Whether the physical-region-to-meridiem mapping is flipped. bool get isHourRegionFlipped => _isHourRegionFlipped(selectedAmPm); bool _isHourRegionFlipped(int selectedAmPm) => selectedAmPm != meridiemRegion; // The index of the 12-hour region the hour picker is currently in. // // Used to determine whether the meridiemController should start animating. // Valid values are 0 and 1. // // The AM/PM correspondence of the two regions flips when the meridiem picker // scrolls. This variable is to keep track of the selected "physical" // (meridiem picker invariant) region of the hour picker. The "physical" region // of an item of index `i` is `i ~/ 12`. int meridiemRegion; // The current selection of the AM/PM picker. // // - 0 means AM // - 1 means PM FixedExtentScrollController meridiemController; bool isDatePickerScrolling = false; bool isHourPickerScrolling = false; bool isMinutePickerScrolling = false; bool isMeridiemPickerScrolling = false; bool get isScrolling { return isDatePickerScrolling || isHourPickerScrolling || isMinutePickerScrolling || isMeridiemPickerScrolling; } // The estimated width of columns. final Map<int, double> estimatedColumnWidths = <int, double>{}; @override void initState() { super.initState(); initialDateTime = widget.initialDateTime; // Initially each of the "physical" regions is mapped to the meridiem region // with the same number, e.g., the first 12 items are mapped to the first 12 // hours of a day. Such mapping is flipped when the meridiem picker is scrolled // by the user, the first 12 items are mapped to the last 12 hours of a day. selectedAmPm = initialDateTime.hour ~/ 12; meridiemRegion = selectedAmPm; meridiemController = FixedExtentScrollController(initialItem: selectedAmPm); hourController = FixedExtentScrollController(initialItem: initialDateTime.hour); minuteController = FixedExtentScrollController(initialItem: initialDateTime.minute ~/ widget.minuteInterval); dateController = FixedExtentScrollController(initialItem: 0); PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); } void _handleSystemFontsChange () { setState(() { // System fonts change might cause the text layout width to change. // Clears cached width to ensure that they get recalculated with the // new system fonts. estimatedColumnWidths.clear(); }); } @override void dispose() { dateController.dispose(); hourController.dispose(); minuteController.dispose(); meridiemController.dispose(); PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); super.dispose(); } @override void didUpdateWidget(CupertinoDatePicker oldWidget) { super.didUpdateWidget(oldWidget); assert( oldWidget.mode == widget.mode, "The $runtimeType's mode cannot change once it's built.", ); if (!widget.use24hFormat && oldWidget.use24hFormat) { // Thanks to the physical and meridiem region mapping, the only thing we // need to update is the meridiem controller, if it's not previously attached. meridiemController.dispose(); meridiemController = FixedExtentScrollController(initialItem: selectedAmPm); } } @override void didChangeDependencies() { super.didChangeDependencies(); textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; localizations = CupertinoLocalizations.of(context); alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; estimatedColumnWidths.clear(); } // Lazily calculate the column width of the column being displayed only. double _getEstimatedColumnWidth(_PickerColumnType columnType) { if (estimatedColumnWidths[columnType.index] == null) { estimatedColumnWidths[columnType.index] = CupertinoDatePicker._getColumnWidth(columnType, localizations, context); } return estimatedColumnWidths[columnType.index]; } // Gets the current date time of the picker. DateTime get selectedDateTime { return DateTime( initialDateTime.year, initialDateTime.month, initialDateTime.day + selectedDayFromInitial, selectedHour, selectedMinute, ); } // Only reports datetime change when the date time is valid. void _onSelectedItemChange(int index) { final DateTime selected = selectedDateTime; final bool isDateInvalid = widget.minimumDate?.isAfter(selected) == true || widget.maximumDate?.isBefore(selected) == true; if (isDateInvalid) return; widget.onDateTimeChanged(selected); } // Builds the date column. The date is displayed in medium date format (e.g. Fri Aug 31). Widget _buildMediumDatePicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isDatePickerScrolling = true; } else if (notification is ScrollEndNotification) { isDatePickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker.builder( scrollController: dateController, offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { _onSelectedItemChange(index); }, itemBuilder: (BuildContext context, int index) { final DateTime rangeStart = DateTime( initialDateTime.year, initialDateTime.month, initialDateTime.day + index, ); // Exclusive. final DateTime rangeEnd = DateTime( initialDateTime.year, initialDateTime.month, initialDateTime.day + index + 1, ); final DateTime now = DateTime.now(); if (widget.minimumDate?.isAfter(rangeEnd) == true) return null; if (widget.maximumDate?.isAfter(rangeStart) == false) return null; final String dateText = rangeStart == DateTime(now.year, now.month, now.day) ? localizations.todayLabel : localizations.datePickerMediumDate(rangeStart); return itemPositioningBuilder( context, Text(dateText, style: _themeTextStyle(context)), ); }, ), ); } // With the meridem picker set to `meridiemIndex`, and the hour picker set to // `hourIndex`, is it possible to change the value of the minute picker, so // that the resulting date stays in the valid range. bool _isValidHour(int meridiemIndex, int hourIndex) { final DateTime rangeStart = DateTime( initialDateTime.year, initialDateTime.month, initialDateTime.day + selectedDayFromInitial, _selectedHour(meridiemIndex, hourIndex), 0, ); // The end value of the range is exclusive, i.e. [rangeStart, rangeEnd). final DateTime rangeEnd = rangeStart.add(const Duration(hours: 1)); return (widget.minimumDate?.isBefore(rangeEnd) ?? true) && !(widget.maximumDate?.isBefore(rangeStart) ?? false); } Widget _buildHourPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isHourPickerScrolling = true; } else if (notification is ScrollEndNotification) { isHourPickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker( scrollController: hourController, offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { final bool regionChanged = meridiemRegion != index ~/ 12; final bool debugIsFlipped = isHourRegionFlipped; if (regionChanged) { meridiemRegion = index ~/ 12; selectedAmPm = 1 - selectedAmPm; } if (!widget.use24hFormat && regionChanged) { // Scroll the meridiem column to adjust AM/PM. // // _onSelectedItemChanged will be called when the animation finishes. // // Animation values obtained by comparing with iOS version. meridiemController.animateToItem( selectedAmPm, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } else { _onSelectedItemChange(index); } assert(debugIsFlipped == isHourRegionFlipped); }, children: List<Widget>.generate(24, (int index) { final int hour = isHourRegionFlipped ? (index + 12) % 24 : index; final int displayHour = widget.use24hFormat ? hour : (hour + 11) % 12 + 1; return itemPositioningBuilder( context, Text( localizations.datePickerHour(displayHour), semanticsLabel: localizations.datePickerHourSemanticsLabel(displayHour), style: _themeTextStyle(context, isValid: _isValidHour(selectedAmPm, index)), ), ); }), looping: true, ) ); } Widget _buildMinutePicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isMinutePickerScrolling = true; } else if (notification is ScrollEndNotification) { isMinutePickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker( scrollController: minuteController, offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: _onSelectedItemChange, children: List<Widget>.generate(60 ~/ widget.minuteInterval, (int index) { final int minute = index * widget.minuteInterval; final DateTime date = DateTime( initialDateTime.year, initialDateTime.month, initialDateTime.day + selectedDayFromInitial, selectedHour, minute, ); final bool isInvalidMinute = (widget.minimumDate?.isAfter(date) ?? false) || (widget.maximumDate?.isBefore(date) ?? false); return itemPositioningBuilder( context, Text( localizations.datePickerMinute(minute), semanticsLabel: localizations.datePickerMinuteSemanticsLabel(minute), style: _themeTextStyle(context, isValid: !isInvalidMinute), ), ); }), looping: true, ), ); } Widget _buildAmPmPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { isMeridiemPickerScrolling = true; } else if (notification is ScrollEndNotification) { isMeridiemPickerScrolling = false; _pickerDidStopScrolling(); } return false; }, child: CupertinoPicker( scrollController: meridiemController, offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, useMagnifier: _kUseMagnifier, magnification: _kMagnification, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { selectedAmPm = index; assert(selectedAmPm == 0 || selectedAmPm == 1); _onSelectedItemChange(index); }, children: List<Widget>.generate(2, (int index) { return itemPositioningBuilder( context, Text( index == 0 ? localizations.anteMeridiemAbbreviation : localizations.postMeridiemAbbreviation, style: _themeTextStyle(context, isValid: _isValidHour(index, _selectedHourIndex)), ), ); }), ), ); } // One or more pickers have just stopped scrolling. void _pickerDidStopScrolling() { // Call setState to update the greyed out date/hour/minute/meridiem. setState(() { }); if (isScrolling) return; // Whenever scrolling lands on an invalid entry, the picker // automatically scrolls to a valid one. final DateTime selectedDate = selectedDateTime; final bool minCheck = widget.minimumDate?.isAfter(selectedDate) ?? false; final bool maxCheck = widget.maximumDate?.isBefore(selectedDate) ?? false; if (minCheck || maxCheck) { // We have minCheck === !maxCheck. final DateTime targetDate = minCheck ? widget.minimumDate : widget.maximumDate; _scrollToDate(targetDate, selectedDate); } } void _scrollToDate(DateTime newDate, DateTime fromDate) { assert(newDate != null); SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { if (fromDate.year != newDate.year || fromDate.month != newDate.month || fromDate.day != newDate.day) { _animateColumnControllerToItem(dateController, selectedDayFromInitial); } if (fromDate.hour != newDate.hour) { final bool needsMeridiemChange = !widget.use24hFormat && fromDate.hour ~/ 12 != newDate.hour ~/ 12; // In AM/PM mode, the pickers should not scroll all the way to the other hour region. if (needsMeridiemChange) { _animateColumnControllerToItem(meridiemController, 1 - meridiemController.selectedItem); // Keep the target item index in the current 12-h region. final int newItem = (hourController.selectedItem ~/ 12) * 12 + (hourController.selectedItem + newDate.hour - fromDate.hour) % 12; _animateColumnControllerToItem(hourController, newItem); } else { _animateColumnControllerToItem( hourController, hourController.selectedItem + newDate.hour - fromDate.hour, ); } } if (fromDate.minute != newDate.minute) { _animateColumnControllerToItem(minuteController, newDate.minute); } }); } @override Widget build(BuildContext context) { // Widths of the columns in this picker, ordered from left to right. final List<double> columnWidths = <double>[ _getEstimatedColumnWidth(_PickerColumnType.hour), _getEstimatedColumnWidth(_PickerColumnType.minute), ]; final List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[ _buildHourPicker, _buildMinutePicker, ]; // Adds am/pm column if the picker is not using 24h format. if (!widget.use24hFormat) { if (localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.date_time_dayPeriod || localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.time_dayPeriod_date) { pickerBuilders.add(_buildAmPmPicker); columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); } else { pickerBuilders.insert(0, _buildAmPmPicker); columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); } } // Adds medium date column if the picker's mode is date and time. if (widget.mode == CupertinoDatePickerMode.dateAndTime) { if (localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.time_dayPeriod_date || localizations.datePickerDateTimeOrder == DatePickerDateTimeOrder.dayPeriod_time_date) { pickerBuilders.add(_buildMediumDatePicker); columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.date)); } else { pickerBuilders.insert(0, _buildMediumDatePicker); columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.date)); } } final List<Widget> pickers = <Widget>[]; for (int i = 0; i < columnWidths.length; i++) { double offAxisFraction = 0.0; if (i == 0) offAxisFraction = -_kMaximumOffAxisFraction * textDirectionFactor; else if (i >= 2 || columnWidths.length == 2) offAxisFraction = _kMaximumOffAxisFraction * textDirectionFactor; EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize); if (i == columnWidths.length - 1) padding = padding.flipped; if (textDirectionFactor == -1) padding = padding.flipped; pickers.add(LayoutId( id: i, child: pickerBuilders[i]( offAxisFraction, (BuildContext context, Widget child) { return Container( alignment: i == columnWidths.length - 1 ? alignCenterLeft : alignCenterRight, padding: padding, child: Container( alignment: i == columnWidths.length - 1 ? alignCenterLeft : alignCenterRight, width: i == 0 || i == columnWidths.length - 1 ? null : columnWidths[i] + _kDatePickerPadSize, child: child, ), ); }, ), )); } return MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: DefaultTextStyle.merge( style: _kDefaultPickerTextStyle, child: CustomMultiChildLayout( delegate: _DatePickerLayoutDelegate( columnWidths: columnWidths, textDirectionFactor: textDirectionFactor, ), children: pickers, ), ), ); } } class _CupertinoDatePickerDateState extends State<CupertinoDatePicker> { int textDirectionFactor; CupertinoLocalizations localizations; // Alignment based on text direction. The variable name is self descriptive, // however, when text direction is rtl, alignment is reversed. Alignment alignCenterLeft; Alignment alignCenterRight; // The currently selected values of the picker. int selectedDay; int selectedMonth; 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. FixedExtentScrollController dayController; FixedExtentScrollController monthController; FixedExtentScrollController yearController; bool isDayPickerScrolling = false; bool isMonthPickerScrolling = false; bool isYearPickerScrolling = false; bool get isScrolling => isDayPickerScrolling || isMonthPickerScrolling || isYearPickerScrolling; // Estimated width of columns. Map<int, double> estimatedColumnWidths = <int, double>{}; @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; localizations = CupertinoLocalizations.of(context); alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; _refreshEstimatedColumnWidths(); } void _refreshEstimatedColumnWidths() { estimatedColumnWidths[_PickerColumnType.dayOfMonth.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.dayOfMonth, localizations, context); estimatedColumnWidths[_PickerColumnType.month.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.month, localizations, context); estimatedColumnWidths[_PickerColumnType.year.index] = CupertinoDatePicker._getColumnWidth(_PickerColumnType.year, localizations, 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) { final int daysInCurrentMonth = _lastDayInMonth(selectedYear, selectedMonth).day; return NotificationListener<ScrollNotification>( 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)); }, children: List<Widget>.generate(31, (int index) { final int day = index + 1; return itemPositioningBuilder( context, Text( localizations.datePickerDayOfMonth(day), style: _themeTextStyle(context, isValid: day <= daysInCurrentMonth), ), ); }), looping: true, ), ); } Widget _buildMonthPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( 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)); }, children: List<Widget>.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( localizations.datePickerMonth(month), style: _themeTextStyle(context, isValid: !isInvalidMonth), ), ); }), looping: true, ), ); } Widget _buildYearPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder) { return NotificationListener<ScrollNotification>( 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( localizations.datePickerYear(year), style: _themeTextStyle(context, isValid: isValidYear), ), ); }, ), ); } 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) { assert(newDate != null); 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) { List<_ColumnBuilder> pickerBuilders = <_ColumnBuilder>[]; List<double> columnWidths = <double>[]; switch (localizations.datePickerDateOrder) { case DatePickerDateOrder.mdy: pickerBuilders = <_ColumnBuilder>[_buildMonthPicker, _buildDayPicker, _buildYearPicker]; columnWidths = <double>[ estimatedColumnWidths[_PickerColumnType.month.index], estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], estimatedColumnWidths[_PickerColumnType.year.index]]; break; case DatePickerDateOrder.dmy: pickerBuilders = <_ColumnBuilder>[_buildDayPicker, _buildMonthPicker, _buildYearPicker]; columnWidths = <double>[ estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], estimatedColumnWidths[_PickerColumnType.month.index], estimatedColumnWidths[_PickerColumnType.year.index]]; break; case DatePickerDateOrder.ymd: pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildMonthPicker, _buildDayPicker]; columnWidths = <double>[ estimatedColumnWidths[_PickerColumnType.year.index], estimatedColumnWidths[_PickerColumnType.month.index], estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]]; break; case DatePickerDateOrder.ydm: pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildDayPicker, _buildMonthPicker]; columnWidths = <double>[ estimatedColumnWidths[_PickerColumnType.year.index], estimatedColumnWidths[_PickerColumnType.dayOfMonth.index], estimatedColumnWidths[_PickerColumnType.month.index]]; break; default: assert(false, 'date order is not specified'); } final List<Widget> pickers = <Widget>[]; 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); 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, ), ); }, ), )); } return MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: DefaultTextStyle.merge( style: _kDefaultPickerTextStyle, child: CustomMultiChildLayout( delegate: _DatePickerLayoutDelegate( columnWidths: columnWidths, textDirectionFactor: textDirectionFactor, ), children: pickers, ), ), ); } } // The iOS date picker and timer picker has their width fixed to 320.0 in all // modes. The only exception is the hms mode (which doesn't have a native counterpart), // with a fixed width of 330.0 px. // // For date pickers, if the maximum width given to the picker is greater than // 320.0, the leftmost and rightmost column will be extended equally so that the // widths match, and the picker is in the center. // // For timer pickers, if the maximum width given to the picker is greater than // its intrinsic width, it will keep its intrinsic size and position itself in the // parent using its alignment parameter. // // If the maximum width given to the picker is smaller than 320.0, the picker's // layout will be broken. /// Different modes of [CupertinoTimerPicker]. /// /// See also: /// /// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. /// * [CupertinoPicker], the class that implements a content agnostic spinner UI. enum CupertinoTimerPickerMode { /// Mode that shows the timer duration in hour and minute. /// /// Examples: 16 hours | 14 min. hm, /// Mode that shows the timer duration in minute and second. /// /// Examples: 14 min | 43 sec. ms, /// Mode that shows the timer duration in hour, minute, and second. /// /// Examples: 16 hours | 14 min | 43 sec. hms, } /// A countdown timer picker in iOS style. /// /// This picker shows a countdown duration with hour, minute and second spinners. /// The duration is bound between 0 and 23 hours 59 minutes 59 seconds. /// /// There are several modes of the timer picker listed in [CupertinoTimerPickerMode]. /// /// The picker has a fixed size of 320 x 216, in logical pixels, with the exception /// of [CupertinoTimerPickerMode.hms], which is 330 x 216. If the parent widget /// provides more space than it needs, the picker will position itself according /// to its [alignment] property. /// /// 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. class CupertinoTimerPicker extends StatefulWidget { /// Constructs an iOS style countdown timer picker. /// /// [mode] is one of the modes listed in [CupertinoTimerPickerMode] and /// defaults to [CupertinoTimerPickerMode.hms]. /// /// [onTimerDurationChanged] is the callback called when the selected duration /// changes and must not be null. /// /// [initialTimerDuration] defaults to 0 second and is limited from 0 second /// to 23 hours 59 minutes 59 seconds. /// /// [minuteInterval] is the granularity of the minute spinner. Must be a /// positive integer factor of 60. /// /// [secondInterval] is the granularity of the second spinner. Must be a /// positive integer factor of 60. CupertinoTimerPicker({ Key key, this.mode = CupertinoTimerPickerMode.hms, this.initialTimerDuration = Duration.zero, this.minuteInterval = 1, this.secondInterval = 1, this.alignment = Alignment.center, this.backgroundColor, @required this.onTimerDurationChanged, }) : assert(mode != null), assert(onTimerDurationChanged != null), assert(initialTimerDuration >= Duration.zero), assert(initialTimerDuration < const Duration(days: 1)), assert(minuteInterval > 0 && 60 % minuteInterval == 0), assert(secondInterval > 0 && 60 % secondInterval == 0), assert(initialTimerDuration.inMinutes % minuteInterval == 0), assert(initialTimerDuration.inSeconds % secondInterval == 0), assert(alignment != null), super(key: key); /// The mode of the timer picker. final CupertinoTimerPickerMode mode; /// The initial duration of the countdown timer. final Duration initialTimerDuration; /// The granularity of the minute spinner. Must be a positive integer factor /// of 60. final int minuteInterval; /// The granularity of the second spinner. Must be a positive integer factor /// of 60. final int secondInterval; /// Callback called when the timer duration changes. final ValueChanged<Duration> onTimerDurationChanged; /// Defines how the timer picker should be positioned within its parent. /// /// This property must not be null. It defaults to [Alignment.center]. final AlignmentGeometry alignment; /// Background color of timer picker. /// /// Defaults to null, which disables background painting entirely. final Color backgroundColor; @override State<StatefulWidget> createState() => _CupertinoTimerPickerState(); } class _CupertinoTimerPickerState extends State<CupertinoTimerPicker> { TextDirection textDirection; CupertinoLocalizations localizations; int get textDirectionFactor { switch (textDirection) { case TextDirection.ltr: return 1; case TextDirection.rtl: return -1; } return 1; } // The currently selected values of the picker. int selectedHour; int selectedMinute; int selectedSecond; // On iOS the selected values won't be reported until the scrolling fully stops. // The values below are the latest selected values when the picker comes to a full stop. int lastSelectedHour; int lastSelectedMinute; int lastSelectedSecond; final TextPainter textPainter = TextPainter(); final List<String> numbers = List<String>.generate(10, (int i) => '${9 - i}'); double numberLabelWidth; double numberLabelHeight; double numberLabelBaseline; @override void initState() { super.initState(); selectedMinute = widget.initialTimerDuration.inMinutes % 60; if (widget.mode != CupertinoTimerPickerMode.ms) selectedHour = widget.initialTimerDuration.inHours; if (widget.mode != CupertinoTimerPickerMode.hm) selectedSecond = widget.initialTimerDuration.inSeconds % 60; PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); } void _handleSystemFontsChange() { setState(() { // System fonts change might cause the text layout width to change. textPainter.markNeedsLayout(); _measureLabelMetrics(); }); } @override void dispose() { PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); super.dispose(); } @override void didUpdateWidget(CupertinoTimerPicker oldWidget) { super.didUpdateWidget(oldWidget); assert( oldWidget.mode == widget.mode, "The CupertinoTimerPicker's mode cannot change once it's built", ); } @override void didChangeDependencies() { super.didChangeDependencies(); textDirection = Directionality.of(context); localizations = CupertinoLocalizations.of(context); _measureLabelMetrics(); } void _measureLabelMetrics() { textPainter.textDirection = textDirection; final TextStyle textStyle = _textStyleFrom(context); double maxWidth = double.negativeInfinity; String widestNumber; // Assumes that: // - 2-digit numbers are always wider than 1-digit numbers. // - There's at least one number in 1-9 that's wider than or equal to 0. // - The widest 2-digit number is composed of 2 same 1-digit numbers // that has the biggest width. // - If two different 1-digit numbers are of the same width, their corresponding // 2 digit numbers are of the same width. for (final String input in numbers) { textPainter.text = TextSpan( text: input, style: textStyle, ); textPainter.layout(); if (textPainter.maxIntrinsicWidth > maxWidth) { maxWidth = textPainter.maxIntrinsicWidth; widestNumber = input; } } textPainter.text = TextSpan( text: '$widestNumber$widestNumber', style: textStyle, ); textPainter.layout(); numberLabelWidth = textPainter.maxIntrinsicWidth; numberLabelHeight = textPainter.height; numberLabelBaseline = textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); } // Builds a text label with scale factor 1.0 and font weight semi-bold. // `pickerPadding ` is the additional padding the corresponding picker has to apply // around the `Text`, in order to extend its separators towards the closest // horizontal edge of the encompassing widget. Widget _buildLabel(String text, EdgeInsetsDirectional pickerPadding) { final EdgeInsetsDirectional padding = EdgeInsetsDirectional.only( start: numberLabelWidth + _kTimerPickerLabelPadSize + pickerPadding.start, ); return IgnorePointer( child: Container( alignment: AlignmentDirectional.centerStart.resolve(textDirection), padding: padding.resolve(textDirection), child: SizedBox( height: numberLabelHeight, child: Baseline( baseline: numberLabelBaseline, baselineType: TextBaseline.alphabetic, child: Text( text, style: const TextStyle( fontSize: _kTimerPickerLabelFontSize, fontWeight: FontWeight.w600, ), maxLines: 1, softWrap: false, ), ), ), ), ); } // The picker has to be wider than its content, since the separators // are part of the picker. Widget _buildPickerNumberLabel(String text, EdgeInsetsDirectional padding) { return Container( width: _kTimerPickerColumnIntrinsicWidth + padding.horizontal, padding: padding.resolve(textDirection), alignment: AlignmentDirectional.centerStart.resolve(textDirection), child: Container( width: numberLabelWidth, alignment: AlignmentDirectional.centerEnd.resolve(textDirection), child: Text(text, softWrap: false, maxLines: 1, overflow: TextOverflow.visible), ), ); } Widget _buildHourPicker(EdgeInsetsDirectional additionalPadding) { return CupertinoPicker( scrollController: FixedExtentScrollController(initialItem: selectedHour), offAxisFraction: -0.5 * textDirectionFactor, itemExtent: _kItemExtent, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, onSelectedItemChanged: (int index) { setState(() { selectedHour = index; widget.onTimerDurationChanged( Duration( hours: selectedHour, minutes: selectedMinute, seconds: selectedSecond ?? 0)); }); }, children: List<Widget>.generate(24, (int index) { final String semanticsLabel = textDirectionFactor == 1 ? localizations.timerPickerHour(index) + localizations.timerPickerHourLabel(index) : localizations.timerPickerHourLabel(index) + localizations.timerPickerHour(index); return Semantics( label: semanticsLabel, excludeSemantics: true, child: _buildPickerNumberLabel(localizations.timerPickerHour(index), additionalPadding), ); }), ); } Widget _buildHourColumn(EdgeInsetsDirectional additionalPadding) { return Stack( children: <Widget>[ NotificationListener<ScrollEndNotification>( onNotification: (ScrollEndNotification notification) { setState(() { lastSelectedHour = selectedHour; }); return false; }, child: _buildHourPicker(additionalPadding), ), _buildLabel( localizations.timerPickerHourLabel(lastSelectedHour ?? selectedHour), additionalPadding, ), ], ); } Widget _buildMinutePicker(EdgeInsetsDirectional additionalPadding) { double offAxisFraction; switch (widget.mode) { case CupertinoTimerPickerMode.hm: offAxisFraction = 0.5 * textDirectionFactor; break; case CupertinoTimerPickerMode.hms: offAxisFraction = 0.0; break; case CupertinoTimerPickerMode.ms: offAxisFraction = -0.5 * textDirectionFactor; } return CupertinoPicker( scrollController: FixedExtentScrollController( initialItem: selectedMinute ~/ widget.minuteInterval, ), offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, looping: true, onSelectedItemChanged: (int index) { setState(() { selectedMinute = index * widget.minuteInterval; widget.onTimerDurationChanged( Duration( hours: selectedHour ?? 0, minutes: selectedMinute, seconds: selectedSecond ?? 0)); }); }, children: List<Widget>.generate(60 ~/ widget.minuteInterval, (int index) { final int minute = index * widget.minuteInterval; final String semanticsLabel = textDirectionFactor == 1 ? localizations.timerPickerMinute(minute) + localizations.timerPickerMinuteLabel(minute) : localizations.timerPickerMinuteLabel(minute) + localizations.timerPickerMinute(minute); return Semantics( label: semanticsLabel, excludeSemantics: true, child: _buildPickerNumberLabel(localizations.timerPickerMinute(minute), additionalPadding), ); }), ); } Widget _buildMinuteColumn(EdgeInsetsDirectional additionalPadding) { return Stack( children: <Widget>[ NotificationListener<ScrollEndNotification>( onNotification: (ScrollEndNotification notification) { setState(() { lastSelectedMinute = selectedMinute; }); return false; }, child: _buildMinutePicker(additionalPadding), ), _buildLabel( localizations.timerPickerMinuteLabel(lastSelectedMinute ?? selectedMinute), additionalPadding, ), ], ); } Widget _buildSecondPicker(EdgeInsetsDirectional additionalPadding) { final double offAxisFraction = 0.5 * textDirectionFactor; return CupertinoPicker( scrollController: FixedExtentScrollController( initialItem: selectedSecond ~/ widget.secondInterval, ), offAxisFraction: offAxisFraction, itemExtent: _kItemExtent, backgroundColor: widget.backgroundColor, squeeze: _kSqueeze, looping: true, onSelectedItemChanged: (int index) { setState(() { selectedSecond = index * widget.secondInterval; widget.onTimerDurationChanged( Duration( hours: selectedHour ?? 0, minutes: selectedMinute, seconds: selectedSecond)); }); }, children: List<Widget>.generate(60 ~/ widget.secondInterval, (int index) { final int second = index * widget.secondInterval; final String semanticsLabel = textDirectionFactor == 1 ? localizations.timerPickerSecond(second) + localizations.timerPickerSecondLabel(second) : localizations.timerPickerSecondLabel(second) + localizations.timerPickerSecond(second); return Semantics( label: semanticsLabel, excludeSemantics: true, child: _buildPickerNumberLabel(localizations.timerPickerSecond(second), additionalPadding), ); }), ); } Widget _buildSecondColumn(EdgeInsetsDirectional additionalPadding) { return Stack( children: <Widget>[ NotificationListener<ScrollEndNotification>( onNotification: (ScrollEndNotification notification) { setState(() { lastSelectedSecond = selectedSecond; }); return false; }, child: _buildSecondPicker(additionalPadding), ), _buildLabel( localizations.timerPickerSecondLabel(lastSelectedSecond ?? selectedSecond), additionalPadding, ), ], ); } TextStyle _textStyleFrom(BuildContext context) { return CupertinoTheme.of(context).textTheme .pickerTextStyle.merge( const TextStyle( fontSize: _kTimerPickerNumberLabelFontSize, ), ); } @override Widget build(BuildContext context) { // The timer picker can be divided into columns corresponding to hour, // minute, and second. Each column consists of a scrollable and a fixed // label on top of it. List<Widget> columns; const double paddingValue = _kPickerWidth - 2 * _kTimerPickerColumnIntrinsicWidth - 2 * _kTimerPickerHalfColumnPadding; // The default totalWidth for 2-column modes. double totalWidth = _kPickerWidth; assert(paddingValue >= 0); switch (widget.mode) { case CupertinoTimerPickerMode.hm: // Pad the widget to make it as wide as `_kPickerWidth`. columns = <Widget>[ _buildHourColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)), _buildMinuteColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)), ]; break; case CupertinoTimerPickerMode.ms: // Pad the widget to make it as wide as `_kPickerWidth`. columns = <Widget>[ _buildMinuteColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)), _buildSecondColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)), ]; break; case CupertinoTimerPickerMode.hms: const double paddingValue = _kTimerPickerHalfColumnPadding * 2; totalWidth = _kTimerPickerColumnIntrinsicWidth * 3 + 4 * _kTimerPickerHalfColumnPadding + paddingValue; columns = <Widget>[ _buildHourColumn(const EdgeInsetsDirectional.only(start: paddingValue / 2, end: _kTimerPickerHalfColumnPadding)), _buildMinuteColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: _kTimerPickerHalfColumnPadding)), _buildSecondColumn(const EdgeInsetsDirectional.only(start: _kTimerPickerHalfColumnPadding, end: paddingValue / 2)), ]; break; } final CupertinoThemeData themeData = CupertinoTheme.of(context); return MediaQuery( // The native iOS picker's text scaling is fixed, so we will also fix it // as well in our picker. data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), child: CupertinoTheme( data: themeData.copyWith( textTheme: themeData.textTheme.copyWith( pickerTextStyle: _textStyleFrom(context), ), ), child: Align( alignment: widget.alignment, child: Container( color: CupertinoDynamicColor.resolve(widget.backgroundColor, context), width: totalWidth, height: _kPickerHeight, child: DefaultTextStyle( style: _textStyleFrom(context), child: Row(children: columns.map((Widget child) => Expanded(child: child)).toList(growable: false)), ), ), ), ), ); } }