From 91a3f69f11843373d03f9c94e8ab7683125241a3 Mon Sep 17 00:00:00 2001
From: Mitchell Goodwin <58190796+MitchellGoodwin@users.noreply.github.com>
Date: Wed, 31 Jul 2024 12:56:15 -0700
Subject: [PATCH] Calendar font factor (#152341)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Increases the max text can be scaled for the date picker in calendar mode and input mode. Previously the max across the whole widget was 1.3. Due to the size of the widget, this was increased as much as possible with different values used in different places. Testing and screenshots where taken on the iPhone SE 3rd generation simulator, set at max font size, which is a value of 3.0. Android has a lower max font scale value of 2.0, and the iPhone SE is about the smallest phone with a lower pixel density ratio.
Fixes internal issues b/316958515 and b/316959677
Also fixes #61334
Comparison for calendar mode in portrait and landscape:
| Before | After |
| -------- | ------- |
|
|
|
|
|
|
|
|
|
|
|
|
The title text is smaller when the entry mode button is available:
Adjustments were made to input mode as well, but they are simpler
Date range picker was not adjusted with this PR. It still has a max of 1.3.
---
.../src/material/calendar_date_picker.dart | 101 +++++++++----
.../flutter/lib/src/material/date_picker.dart | 140 ++++++++++++------
.../test/material/date_picker_test.dart | 29 ++++
3 files changed, 194 insertions(+), 76 deletions(-)
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', () {