diff --git a/packages/flutter/lib/src/material/calendar_date_picker.dart b/packages/flutter/lib/src/material/calendar_date_picker.dart index 59e1169aea..5de14f5f9b 100644 --- a/packages/flutter/lib/src/material/calendar_date_picker.dart +++ b/packages/flutter/lib/src/material/calendar_date_picker.dart @@ -42,6 +42,25 @@ const double _yearPickerRowSpacing = 8.0; const double _subHeaderHeight = 52.0; const double _monthNavButtonsWidth = 108.0; +// 3.0 is the maximum scale factor on mobile phones. As of 07/30/24, iOS goes up +// to a max of 3.0 text sxale factor, and Android goes up to 2.0. This is the +// default used for non-range date pickers. This default is changed to a lower +// value at different parts of the date pickers depending on content, and device +// orientation. +const double _kMaxTextScaleFactor = 3.0; + +const double _kModeToggleButtonMaxScaleFactor = 2.0; + +// The max scale factor of the day picker grid. This affects the size of the +// individual days in calendar view. Due to them filling a majority of the modal, +// which covers most of the screen, there's a limit in how large they can grow. +// There is also less room vertically in landscape orientation. +const double _kDayPickerGridPortraitMaxScaleFactor = 2.0; +const double _kDayPickerGridLandscapeMaxScaleFactor = 1.5; + +// 14 is a common font size used to compute the effective text scale. +const double _fontSizeToScale = 14.0; + /// Displays a grid of days for a given month and allows the user to select a /// date. /// @@ -319,20 +338,30 @@ class _CalendarDatePickerState extends State { assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterialLocalizations(context)); assert(debugCheckHasDirectionality(context)); + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(_fontSizeToScale) / _fontSizeToScale; + // Scale the height of the picker area up with larger text. The size of the + // picker has room for larger text, up until a scale facotr of 1.3. After + // after which, we increase the height to add room for content to continue + // to scale the text size. + final double scaledMaxDayPickerHeight = + textScaleFactor > 1.3 ? _maxDayPickerHeight + ((_maxDayPickerRowCount + 1) * ((textScaleFactor - 1) * 8)) : _maxDayPickerHeight; return Stack( children: [ SizedBox( - height: _subHeaderHeight + _maxDayPickerHeight, + height: _subHeaderHeight + scaledMaxDayPickerHeight, child: _buildPicker(), ), // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker - _DatePickerModeToggleButton( - mode: _mode, - title: _localizations.formatMonthYear(_currentDisplayedMonthDate), - onTitlePressed: () => _handleModeChanged(switch (_mode) { - DatePickerMode.day => DatePickerMode.year, - DatePickerMode.year => DatePickerMode.day, - }), + MediaQuery.withClampedTextScaling( + maxScaleFactor: _kModeToggleButtonMaxScaleFactor, + child: _DatePickerModeToggleButton( + mode: _mode, + title: _localizations.formatMonthYear(_currentDisplayedMonthDate), + onTitlePressed: () => _handleModeChanged(switch (_mode) { + DatePickerMode.day => DatePickerMode.year, + DatePickerMode.year => DatePickerMode.day, + }), + ), ), ], ); @@ -949,6 +978,9 @@ class _DayPickerState extends State<_DayPicker> { final DatePickerThemeData defaults = DatePickerTheme.defaults(context); final TextStyle? weekdayStyle = datePickerTheme.weekdayStyle ?? defaults.weekdayStyle; + final Orientation orientation = MediaQuery.orientationOf(context); + final bool isLandscapeOrientation = orientation == Orientation.landscape; + final int year = widget.displayedMonth.year; final int month = widget.displayedMonth.month; @@ -990,12 +1022,17 @@ class _DayPickerState extends State<_DayPicker> { padding: const EdgeInsets.symmetric( horizontal: _monthPickerHorizontalPadding, ), - child: GridView.custom( - physics: const ClampingScrollPhysics(), - gridDelegate: _dayPickerGridDelegate, - childrenDelegate: SliverChildListDelegate( - dayItems, - addRepaintBoundaries: false, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: isLandscapeOrientation ? + _kDayPickerGridLandscapeMaxScaleFactor : + _kDayPickerGridPortraitMaxScaleFactor, + child: GridView.custom( + physics: const ClampingScrollPhysics(), + gridDelegate: _DayPickerGridDelegate(context), + childrenDelegate: SliverChildListDelegate( + dayItems, + addRepaintBoundaries: false, + ), ), ), ); @@ -1120,14 +1157,19 @@ class _DayState extends State<_Day> { } class _DayPickerGridDelegate extends SliverGridDelegate { - const _DayPickerGridDelegate(); + const _DayPickerGridDelegate(this.context); + + final BuildContext context; @override SliverGridLayout getLayout(SliverConstraints constraints) { + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / _fontSizeToScale; + final double scaledRowHeight = + textScaleFactor > 1.3 ? ((textScaleFactor - 1) * 30) + _dayPickerRowHeight : _dayPickerRowHeight; const int columnCount = DateTime.daysPerWeek; final double tileWidth = constraints.crossAxisExtent / columnCount; final double tileHeight = math.min( - _dayPickerRowHeight, + scaledRowHeight, constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1), ); return SliverGridRegularTileLayout( @@ -1144,8 +1186,6 @@ class _DayPickerGridDelegate extends SliverGridDelegate { bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false; } -const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate(); - /// A scrollable grid of years to allow picking a year. /// /// The year picker widget is rarely used directly. Instead, consider using @@ -1259,14 +1299,16 @@ class _YearPickerState extends State { ); } + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / _fontSizeToScale; + // Backfill the _YearPicker with disabled years if necessary. final int offset = _itemCount < minYears ? (minYears - _itemCount) ~/ 2 : 0; final int year = widget.firstDate.year + index - offset; final bool isSelected = year == widget.selectedDate?.year; final bool isCurrentYear = year == widget.currentDate.year; final bool isDisabled = year < widget.firstDate.year || year > widget.lastDate.year; - const double decorationHeight = 36.0; - const double decorationWidth = 72.0; + final double decorationHeight = 36.0 * textScaleFactor; + final double decorationWidth = 72.0 * textScaleFactor; final Set states = { if (isDisabled) MaterialState.disabled, @@ -1350,7 +1392,7 @@ class _YearPickerState extends State { child: GridView.builder( controller: _scrollController, dragStartBehavior: widget.dragStartBehavior, - gridDelegate: _yearPickerGridDelegate, + gridDelegate: _YearPickerGridDelegate(context), itemBuilder: _buildYearItem, itemCount: math.max(_itemCount, minYears), padding: const EdgeInsets.symmetric(horizontal: _yearPickerPadding), @@ -1363,18 +1405,23 @@ class _YearPickerState extends State { } class _YearPickerGridDelegate extends SliverGridDelegate { - const _YearPickerGridDelegate(); + const _YearPickerGridDelegate(this.context); + + final BuildContext context; @override SliverGridLayout getLayout(SliverConstraints constraints) { + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / _fontSizeToScale; + final int scaledYearPickerColumnCount = textScaleFactor > 1.65 ? _yearPickerColumnCount - 1 : _yearPickerColumnCount; final double tileWidth = - (constraints.crossAxisExtent - (_yearPickerColumnCount - 1) * _yearPickerRowSpacing) / _yearPickerColumnCount; + (constraints.crossAxisExtent - (scaledYearPickerColumnCount - 1) * _yearPickerRowSpacing) / scaledYearPickerColumnCount; + final double scaledYearPickerRowHeight = textScaleFactor > 1 ? _yearPickerRowHeight + (( textScaleFactor - 1 ) * 9) : _yearPickerRowHeight; return SliverGridRegularTileLayout( childCrossAxisExtent: tileWidth, - childMainAxisExtent: _yearPickerRowHeight, - crossAxisCount: _yearPickerColumnCount, + childMainAxisExtent: scaledYearPickerRowHeight, + crossAxisCount: scaledYearPickerColumnCount, crossAxisStride: tileWidth + _yearPickerRowSpacing, - mainAxisStride: _yearPickerRowHeight, + mainAxisStride: scaledYearPickerRowHeight, reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), ); } @@ -1382,5 +1429,3 @@ class _YearPickerGridDelegate extends SliverGridDelegate { @override bool shouldRelayout(_YearPickerGridDelegate oldDelegate) => false; } - -const _YearPickerGridDelegate _yearPickerGridDelegate = _YearPickerGridDelegate(); diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index 318af816e8..d5ca94ef94 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -52,7 +52,30 @@ const Size _inputRangeLandscapeDialogSize = Size(496, 164.0); const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); const double _inputFormPortraitHeight = 98.0; const double _inputFormLandscapeHeight = 108.0; -const double _kMaxTextScaleFactor = 1.3; + +// 3.0 is the maximum scale factor on mobile phones. As of 07/30/24, iOS goes up +// to a max of 3.0 text sxale factor, and Android goes up to 2.0. This is the +// default used for non-range date pickers. This default is changed to a lower +// value at different parts of the date pickers depending on content, and device +// orientation. +const double _kMaxTextScaleFactor = 3.0; + +// The max scale factor for the date range pickers. +const double _kMaxRangeTextScaleFactor = 1.3; + +// The max text scale factor for the header. This is lower than the default as +// the title text already starts at a large size. +const double _kMaxHeaderTextScaleFactor = 1.6; + +// The entry button shares a line with the header text, so there is less room to +// scale up. +const double _kMaxHeaderWithEntryTextScaleFactor = 1.4; + +const double _kMaxHelpPortraitTextScaleFactor = 1.6; +const double _kMaxHelpLandscapeTextScaleFactor = 1.4; + +// 14 is a common font size used to compute the effective text scale. +const double _fontSizeToScale = 14.0; /// Shows a dialog containing a Material Design date picker. /// @@ -524,6 +547,7 @@ class _DatePickerDialogState extends State with RestorationMix final bool useMaterial3 = theme.useMaterial3; final MaterialLocalizations localizations = MaterialLocalizations.of(context); final Orientation orientation = MediaQuery.orientationOf(context); + final bool isLandscapeOrientation = orientation == Orientation.landscape; final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); final DatePickerThemeData defaults = DatePickerTheme.defaults(context); final TextTheme textTheme = theme.textTheme; @@ -546,35 +570,38 @@ class _DatePickerDialogState extends State with RestorationMix // M3 default is OK. } } else { - headlineStyle = orientation == Orientation.landscape ? textTheme.headlineSmall : textTheme.headlineMedium; + headlineStyle = isLandscapeOrientation ? textTheme.headlineSmall : textTheme.headlineMedium; } final Color? headerForegroundColor = datePickerTheme.headerForegroundColor ?? defaults.headerForegroundColor; headlineStyle = headlineStyle?.copyWith(color: headerForegroundColor); final Widget actions = ConstrainedBox( constraints: const BoxConstraints(minHeight: 52.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: OverflowBar( - spacing: 8, - children: [ - TextButton( - style: datePickerTheme.cancelButtonStyle ?? defaults.cancelButtonStyle, - onPressed: _handleCancel, - child: Text(widget.cancelText ?? ( - useMaterial3 - ? localizations.cancelButtonLabel - : localizations.cancelButtonLabel.toUpperCase() - )), - ), - TextButton( - style: datePickerTheme.confirmButtonStyle ?? defaults.confirmButtonStyle, - onPressed: _handleOk, - child: Text(widget.confirmText ?? localizations.okButtonLabel), - ), - ], + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: isLandscapeOrientation ? 1.6 : _kMaxTextScaleFactor, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + spacing: 8, + children: [ + TextButton( + style: datePickerTheme.cancelButtonStyle ?? defaults.cancelButtonStyle, + onPressed: _handleCancel, + child: Text(widget.cancelText ?? ( + useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase() + )), + ), + TextButton( + style: datePickerTheme.confirmButtonStyle ?? defaults.confirmButtonStyle, + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), ), ), ), @@ -604,23 +631,27 @@ class _DatePickerDialogState extends State with RestorationMix child: Shortcuts( shortcuts: _formShortcutMap, child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - const Spacer(), - InputDatePickerFormField( - initialDate: _selectedDate.value, - firstDate: widget.firstDate, - lastDate: widget.lastDate, - onDateSubmitted: _handleDateChanged, - onDateSaved: _handleDateChanged, - selectableDayPredicate: widget.selectableDayPredicate, - errorFormatText: widget.errorFormatText, - errorInvalidText: widget.errorInvalidText, - fieldHintText: widget.fieldHintText, - fieldLabelText: widget.fieldLabelText, - keyboardType: widget.keyboardType, - autofocus: true, + Flexible( + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 2.0, + child: InputDatePickerFormField( + initialDate: _selectedDate.value, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onDateSubmitted: _handleDateChanged, + onDateSaved: _handleDateChanged, + selectableDayPredicate: widget.selectableDayPredicate, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldHintText: widget.fieldHintText, + fieldLabelText: widget.fieldLabelText, + keyboardType: widget.keyboardType, + autofocus: true, + ), + ), ), - const Spacer(), ], ), ), @@ -674,9 +705,7 @@ class _DatePickerDialogState extends State with RestorationMix // Constrain the textScaleFactor to the largest supported value to prevent // layout issues. - // 14 is a common font size used to compute the effective text scale. - const double fontSizeToScale = 14.0; - final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(fontSizeToScale) / fontSizeToScale; + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(_fontSizeToScale) / _fontSizeToScale; final Size dialogSize = _dialogSize(context) * textScaleFactor; final DialogTheme dialogTheme = theme.dialogTheme; return Dialog( @@ -863,27 +892,44 @@ class _DatePickerHeader extends StatelessWidget { final TextStyle? helpStyle = (datePickerTheme.headerHelpStyle ?? defaults.headerHelpStyle)?.copyWith( color: foregroundColor, ); + final double currentScale = MediaQuery.textScalerOf(context).scale(_fontSizeToScale) / _fontSizeToScale; + final double maxHeaderTextScaleFactor = math.min(currentScale, entryModeButton != null ? _kMaxHeaderWithEntryTextScaleFactor : _kMaxHeaderTextScaleFactor); + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: maxHeaderTextScaleFactor).scale(_fontSizeToScale) / _fontSizeToScale; + final double scaledFontSize = MediaQuery.textScalerOf(context).scale(titleStyle?.fontSize ?? 32); + final double headerScaleFactor = textScaleFactor > 1 ? textScaleFactor : 1.0; final Text help = Text( helpText, style: helpStyle, maxLines: 1, overflow: TextOverflow.ellipsis, + textScaler: MediaQuery.textScalerOf(context).clamp( + maxScaleFactor: math.min(textScaleFactor, orientation == Orientation.portrait ? + _kMaxHelpPortraitTextScaleFactor : + _kMaxHelpLandscapeTextScaleFactor + ) + ), ); final Text title = Text( titleText, semanticsLabel: titleSemanticsLabel ?? titleText, style: titleStyle, - maxLines: orientation == Orientation.portrait ? 1 : 2, + maxLines: orientation == Orientation.portrait ? + (scaledFontSize > 70 ? 2 : 1) : + scaledFontSize > 40 ? 3 : 2, overflow: TextOverflow.ellipsis, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: textScaleFactor), ); + final double fontScaleAdjustedHeaderHeight = + headerScaleFactor > 1.3 ? headerScaleFactor - 0.2 : 1.0; + switch (orientation) { case Orientation.portrait: return Semantics( container: true, child: SizedBox( - height: _datePickerHeaderPortraitHeight, + height: _datePickerHeaderPortraitHeight * fontScaleAdjustedHeaderHeight, child: Material( color: backgroundColor, child: Padding( @@ -1612,7 +1658,7 @@ class _DateRangePickerDialogState extends State with Rest duration: _dialogSizeAnimationDuration, curve: Curves.easeIn, child: MediaQuery.withClampedTextScaling( - maxScaleFactor: _kMaxTextScaleFactor, + maxScaleFactor: _kMaxRangeTextScaleFactor, child: Builder(builder: (BuildContext context) { return contents; }), @@ -2916,9 +2962,7 @@ class _InputDateRangePickerDialog extends StatelessWidget { ), ); - // 14 is a common font size used to compute the effective text scale. - const double fontSizeToScale = 14.0; - final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(fontSizeToScale) / fontSizeToScale; + final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: _kMaxRangeTextScaleFactor).scale(_fontSizeToScale) / _fontSizeToScale; final Size dialogSize = (useMaterial3 ? _inputPortraitDialogSizeM3 : _inputPortraitDialogSizeM2) * textScaleFactor; switch (orientation) { case Orientation.portrait: diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index cfb232b106..6eb2b59462 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -1316,6 +1316,35 @@ void main() { expect(find.text('2017'), findsNothing); }); }); + + testWidgets('Calendar dialog contents are visible - textScaler 0.88, 1.0, 2.0', + (WidgetTester tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + final List scales = [0.88, 1.0, 2.0]; + + for (final double scale in scales) { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Material( + child: DatePickerDialog( + firstDate: DateTime(2001), + lastDate: DateTime(2031, DateTime.december, 31), + initialDate: DateTime(2016, DateTime.january, 15), + initialEntryMode: DatePickerEntryMode.calendarOnly, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater(find.byType(Dialog), matchesGoldenFile('date_picker.calendar.contents.visible.$scale.png')); + } + }); }); group('Input mode', () {