-
Notifications
You must be signed in to change notification settings - Fork 30.9k
Expand file tree
/
Copy pathdate_picker.dart
More file actions
2952 lines (2623 loc) · 107 KB
/
Copy pathdate_picker.dart
File metadata and controls
2952 lines (2623 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// 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.
/// @docImport 'route.dart';
/// @docImport 'text_theme.dart';
library;
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);
// The item height is 32 and the magnifier height is 34, from
// iOS simulators with "Debug View Hierarchy".
// And the magnified fontSize by [_kTimerPickerMagnification] conforms to the
// iOS 14 native style by eyeball test.
const double _kTimerPickerMagnification = 34 / 32;
// Minimum horizontal padding between [CupertinoTimerPicker]
//
// It shouldn't actually be hard-coded for direct use, and the perfect solution
// should be to calculate the values that match the magnified values by
// offAxisFraction and _kSqueeze.
// Such calculations are complex, so we'll hard-code them for now.
const double _kTimerPickerMinHorizontalPadding = 30;
// Half of the horizontal padding value between the timer picker's columns.
const double _kTimerPickerHalfColumnPadding = 4;
// The horizontal padding between the timer picker's number label and its
// corresponding unit label.
const double _kTimerPickerLabelPadSize = 6;
const double _kTimerPickerLabelFontSize = 17.0;
// The width of each column of the countdown time picker.
const double _kTimerPickerColumnIntrinsicWidth = 106;
TextStyle _themeTextStyle(BuildContext context, {bool isValid = true}) {
final TextStyle style = CupertinoTheme.of(context).textTheme.dateTimePickerTextStyle;
return isValid
? style.copyWith(color: CupertinoDynamicColor.maybeResolve(style.color, context))
: 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),
);
}
const Widget _startSelectionOverlay = CupertinoPickerDefaultSelectionOverlay(capEndEdge: false);
const Widget _centerSelectionOverlay = CupertinoPickerDefaultSelectionOverlay(
capStartEdge: false,
capEndEdge: false,
);
const Widget _endSelectionOverlay = CupertinoPickerDefaultSelectionOverlay(capStartEdge: false);
/// Defines a function signature for creating a widget that serves as a selection overlay,
/// given the current context, the selected item's index, and the total number of columns.
typedef SelectionOverlayBuilder =
Widget? Function(BuildContext context, {required int columnCount, required int selectedIndex});
// 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<double> 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 (var i = 0; i < columnWidths.length; i++) {
remainingWidth -= columnWidths[i] + _kDatePickerPadSize * 2;
}
for (var 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,
/// Mode that shows the date in month and year.
/// Name of month is spelled in full.
/// Column order is subject to internationalization.
///
/// Example: ` July | 2012 `.
monthYear,
}
// 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,
// Time separator column in time and dateAndTime mode.
timeSeparator,
}
/// 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.
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/pickers/>
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. 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. When in
/// [CupertinoDatePickerMode.monthYear] mode, the day and time will be the
/// start time of the first day of the month.
///
/// [initialDateTime] is the initial date time of the picker. Defaults to the
/// present date and time. 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.
///
/// [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 [CupertinoDatePickerMode.date] and [CupertinoDatePickerMode.monthYear]
/// mode. When using monthYear mode, both [DatePickerDateOrder.dmy] and
/// [DatePickerDateOrder.mdy] will result in the month|year order.
/// Defaults to the locale's default date format/order.
CupertinoDatePicker({
super.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.dateOrder,
this.backgroundColor,
this.showDayOfWeek = false,
this.showTimeSeparator = false,
this.itemExtent = _kItemExtent,
this.selectionOverlayBuilder,
this.selectableDayPredicate,
this.changeReportingBehavior = ChangeReportingBehavior.onScrollUpdate,
}) : initialDateTime = initialDateTime ?? DateTime.now(),
assert(itemExtent > 0, 'item extent should be greater than 0'),
assert(
minuteInterval > 0 && 60 % minuteInterval == 0,
'minute interval is not a positive integer factor of 60',
),
assert(
mode != CupertinoDatePickerMode.dateAndTime ||
minimumDate == null ||
!(initialDateTime ?? DateTime.now()).isBefore(minimumDate),
'initial date is before minimum date',
),
assert(
mode != CupertinoDatePickerMode.dateAndTime ||
maximumDate == null ||
!(initialDateTime ?? DateTime.now()).isAfter(maximumDate),
'initial date is after maximum date',
),
assert(
(mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) ||
(minimumYear >= 1 && (initialDateTime ?? DateTime.now()).year >= minimumYear),
'initial year is not greater than minimum year, or minimum year is not positive',
),
assert(
(mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) ||
maximumYear == null ||
(initialDateTime ?? DateTime.now()).year <= maximumYear,
'initial year is not smaller than maximum year',
),
assert(
(mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) ||
minimumDate == null ||
!minimumDate.isAfter(initialDateTime ?? DateTime.now()),
'initial date ${initialDateTime ?? DateTime.now()} is not greater than or equal to minimumDate $minimumDate',
),
assert(
(mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) ||
maximumDate == null ||
!maximumDate.isBefore(initialDateTime ?? DateTime.now()),
'initial date ${initialDateTime ?? DateTime.now()} is not less than or equal to maximumDate $maximumDate',
),
assert(
(mode == CupertinoDatePickerMode.date) || !showDayOfWeek,
'showDayOfWeek is only supported in date mode',
),
assert(
(initialDateTime ?? DateTime.now()).minute % minuteInterval == 0,
'initial minute is not divisible by minute interval',
),
assert(
!showTimeSeparator ||
mode == CupertinoDatePickerMode.dateAndTime ||
mode == CupertinoDatePickerMode.time,
'showTimeSeparator is only supported in time or dateAndTime modes',
),
assert(
selectableDayPredicate == null ||
initialDateTime == null ||
selectableDayPredicate(initialDateTime),
'$initialDateTime must satisfy provided selectableDayPredicate.',
);
/// The mode of the date picker as one of [CupertinoDatePickerMode]. Defaults
/// to [CupertinoDatePickerMode.dateAndTime]. 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. 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.
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;
/// Determines the order of the columns inside [CupertinoDatePicker] in
/// [CupertinoDatePickerMode.date] and [CupertinoDatePickerMode.monthYear]
/// mode. When using monthYear mode, both [DatePickerDateOrder.dmy] and
/// [DatePickerDateOrder.mdy] will result in the month|year order.
/// Defaults to the locale's default date format/order.
final DatePickerDateOrder? dateOrder;
/// 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.
///
/// The timing of this callback is controlled by [changeReportingBehavior].
final ValueChanged<DateTime> onDateTimeChanged;
/// Background color of date picker.
///
/// Defaults to null, which disables background painting entirely.
final Color? backgroundColor;
/// Whether to show the day of week alongside the day in [CupertinoDatePickerMode.date] mode.
///
/// Defaults to false.
final bool showDayOfWeek;
/// Whether to show the time separator between hour and minute in the time
/// [CupertinoDatePickerMode.time] and datetime [CupertinoDatePickerMode.dateAndTime]
/// picker modes.
///
/// Throws an error if set to true in [CupertinoDatePickerMode.date]
/// and [CupertinoDatePickerMode.monthYear] mode.
///
/// Defaults to false.
final bool showTimeSeparator;
/// Function to provide full control over which [DateTime] can be selected.
final SelectableDayPredicate? selectableDayPredicate;
/// {@macro flutter.cupertino.picker.itemExtent}
///
/// Defaults to a value that matches the default iOS date picker wheel.
final double itemExtent;
/// A function that returns a widget that is overlaid on the picker
/// to highlight the currently selected entry.
///
/// If unspecified, it defaults to a [CupertinoPickerDefaultSelectionOverlay]
/// which is a gray rounded rectangle overlay in iOS 14 style.
///
/// If the selection overlay builder returns null, no overlay will be drawn.
///
/// {@tool snippet}
///
/// This example shows how to recreate the default selection overlay
/// with selectionOverlayBuilder.
///
/// ```dart
/// CupertinoDatePicker(
/// onDateTimeChanged: (DateTime newDateTime) {},
/// mode: CupertinoDatePickerMode.date,
/// initialDateTime: DateTime(2018, 9, 15),
/// selectionOverlayBuilder: (
/// BuildContext context, {
/// required int selectedIndex,
/// required int columnCount,
/// }) {
/// if (selectedIndex == 0) {
/// return const CupertinoPickerDefaultSelectionOverlay(
/// capEndEdge: false,
/// );
/// } else if (selectedIndex == columnCount - 1) {
/// return const CupertinoPickerDefaultSelectionOverlay(
/// capStartEdge: false,
/// );
/// }
/// return const CupertinoPickerDefaultSelectionOverlay(
/// capStartEdge: false,
/// capEndEdge: false,
/// );
/// },
/// )
/// ```
/// {@end-tool}
final SelectionOverlayBuilder? selectionOverlayBuilder;
/// The behavior of reporting the selected date.
///
/// This determines when the [onDateTimeChanged] callback is called.
///
/// Native iOS 18 behavior is [ChangeReportingBehavior.onScrollEnd], which
/// calls the callback only when the scrolling stops.
///
/// Defaults to [ChangeReportingBehavior.onScrollUpdate].
final ChangeReportingBehavior changeReportingBehavior;
@override
State<StatefulWidget> createState() {
// ignore: no_logic_in_create_state, https://github.com/flutter/flutter/issues/70499
return switch (mode) {
// 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.
CupertinoDatePickerMode.time => _CupertinoDatePickerDateTimeState(),
CupertinoDatePickerMode.dateAndTime => _CupertinoDatePickerDateTimeState(),
CupertinoDatePickerMode.date => _CupertinoDatePickerDateState(dateOrder: dateOrder),
CupertinoDatePickerMode.monthYear => _CupertinoDatePickerMonthYearState(dateOrder: dateOrder),
};
}
// Estimate the minimum width that each column needs to layout its content.
static double _getColumnWidth(
_PickerColumnType columnType,
CupertinoLocalizations localizations,
BuildContext context,
bool showDayOfWeek, {
bool standaloneMonth = false,
}) {
final longTexts = <String>[];
switch (columnType) {
case _PickerColumnType.date:
for (var i = 1; i <= 12; i++) {
final String date = localizations.datePickerMediumDate(DateTime(2018, i, 25));
longTexts.add(date);
}
case _PickerColumnType.hour:
for (var i = 0; i < 24; i++) {
final String hour = localizations.datePickerHour(i);
longTexts.add(hour);
}
case _PickerColumnType.minute:
for (var i = 0; i < 60; i++) {
final String minute = localizations.datePickerMinute(i);
longTexts.add(minute);
}
case _PickerColumnType.dayPeriod:
longTexts.add(localizations.anteMeridiemAbbreviation);
longTexts.add(localizations.postMeridiemAbbreviation);
case _PickerColumnType.dayOfMonth:
var longestDayOfMonth = 1;
for (var i = 1; i <= 31; i++) {
final String dayOfMonth = localizations.datePickerDayOfMonth(i);
longTexts.add(dayOfMonth);
longestDayOfMonth = i;
}
if (showDayOfWeek) {
for (var wd = 1; wd < DateTime.daysPerWeek; wd++) {
final String dayOfMonth = localizations.datePickerDayOfMonth(longestDayOfMonth, wd);
longTexts.add(dayOfMonth);
}
}
case _PickerColumnType.month:
for (var i = 1; i <= 12; i++) {
final String month = standaloneMonth
? localizations.datePickerStandaloneMonth(i)
: localizations.datePickerMonth(i);
longTexts.add(month);
}
case _PickerColumnType.year:
longTexts.add(localizations.datePickerYear(2018));
case _PickerColumnType.timeSeparator:
longTexts.add(':');
}
assert(
longTexts.isNotEmpty && longTexts.every((String text) => text.isNotEmpty),
'column type is not appropriate',
);
return getColumnWidth(texts: longTexts, context: context);
}
/// Returns the width of column in the picker.
///
/// This method is intended for testing only. It calculates the width of the
/// widest column in the picker based on the provided list of texts and the
/// given [BuildContext].
@visibleForTesting
static double getColumnWidth({
required List<String> texts,
required BuildContext context,
TextStyle? textStyle,
}) {
return texts
.map(
(String text) => TextPainter.computeMaxIntrinsicWidth(
text: TextSpan(style: textStyle ?? _themeTextStyle(context), text: text),
textDirection: Directionality.of(context),
),
)
.reduce(math.max);
}
}
typedef _ColumnBuilder =
Widget Function(
double offAxisFraction,
TransitionBuilder itemPositioningBuilder,
Widget? selectionOverlay,
);
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;
late int textDirectionFactor;
late CupertinoLocalizations localizations;
// 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;
// Read this out when the state is initially created. Changes in initialDateTime
// in the widget after first build is ignored.
late 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:
case CupertinoDatePickerMode.monthYear:
break;
}
assert(false, '$runtimeType is only meant for dateAndTime mode or time mode');
return 0;
}
// The controller of the date column.
late 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.
late 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.
late 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.
late 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`.
late int meridiemRegion;
// The current selection of the AM/PM picker.
//
// - 0 means AM
// - 1 means PM
late 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();
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) {
estimatedColumnWidths[columnType.index] ??= CupertinoDatePicker._getColumnWidth(
columnType,
localizations,
context,
widget.showDayOfWeek,
);
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 bool isDateInvalid =
(widget.minimumDate?.isAfter(selectedDateTime) ?? false) ||
(widget.maximumDate?.isBefore(selectedDateTime) ?? false);
if (isDateInvalid) {
return;
} else if (!_isSelectableDate(selectedDateTime)) {
return;
}
widget.onDateTimeChanged(selectedDateTime);
}
/// Returns whether the given date is selectable.
bool _isSelectableDate(DateTime date) {
return widget.selectableDayPredicate?.call(date) ?? true;
}
// Builds the date column. The date is displayed in medium date format (e.g. Fri Aug 31).
Widget _buildMediumDatePicker(
double offAxisFraction,
TransitionBuilder itemPositioningBuilder,
Widget? selectionOverlay,
) {
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: widget.itemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
changeReportingBehavior: widget.changeReportingBehavior,
onSelectedItemChanged: (int index) {
_onSelectedItemChange(index);
},
itemBuilder: (BuildContext context, int index) {
final rangeStart = DateTime(
initialDateTime.year,
initialDateTime.month,
initialDateTime.day + index,
);
// Exclusive.
final rangeEnd = DateTime(
initialDateTime.year,
initialDateTime.month,
initialDateTime.day + index + 1,
);
final now = DateTime.now();
if (widget.minimumDate?.isBefore(rangeEnd) == false) {
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);
final bool isDisabled = !_isSelectableDate(rangeStart);
final Widget child = itemPositioningBuilder(
context,
Text(dateText, style: _themeTextStyle(context, isValid: !isDisabled)),
);
return isDisabled ? ExcludeSemantics(child: child) : child;
},
selectionOverlay: selectionOverlay,
),
);
}
// With the meridiem 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 rangeStart = DateTime(
initialDateTime.year,
initialDateTime.month,
initialDateTime.day + selectedDayFromInitial,
_selectedHour(meridiemIndex, hourIndex),
);
// 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,
Widget? selectionOverlay,
) {
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: widget.itemExtent,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
squeeze: _kSqueeze,
changeReportingBehavior: widget.changeReportingBehavior,
onSelectedItemChanged: (int index) {
final 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);
},
looping: true,
selectionOverlay: selectionOverlay,
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;
final bool isDisabled = !_isValidHour(selectedAmPm, index);
final Widget child = itemPositioningBuilder(
context,
Text(
localizations.datePickerHour(displayHour),
semanticsLabel: localizations.datePickerHourSemanticsLabel(displayHour),
style: _themeTextStyle(context, isValid: !isDisabled),
),
);
return isDisabled ? ExcludeSemantics(child: child) : child;
}),
),