Add InputDecoration.visualDensity and InputDecorationTheme.visualDensity (#166834)

## Description

This PR introduces `InputDecoration.visualDensity` and
`InputDecorationTheme.visualDensity`.
See
https://github.com/flutter/flutter/issues/166201#issuecomment-2774622584
for motivation.

## Related Issue

Fixes https://github.com/flutter/flutter/issues/166201

## Tests

Adds 8 tests (4 for filled decoration, 4 for outlined decoration).
This commit is contained in:
Bruno Leroux 2025-04-18 11:23:38 +02:00 committed by GitHub
parent b98efa6a31
commit 8e3e7f4a3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 380 additions and 10 deletions

View File

@ -2257,8 +2257,9 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final VisualDensity visualDensity = decoration.visualDensity ?? themeData.visualDensity;
final InputDecorationTheme defaults =
Theme.of(context).useMaterial3
themeData.useMaterial3
? _InputDecoratorDefaultsM3(context)
: _InputDecoratorDefaultsM2(context);
final InputDecorationTheme inputDecorationTheme = themeData.inputDecorationTheme;
@ -2422,7 +2423,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
child: ConstrainedBox(
constraints:
decoration.prefixIconConstraints ??
themeData.visualDensity.effectiveConstraints(
visualDensity.effectiveConstraints(
const BoxConstraints(
minWidth: kMinInteractiveDimension,
minHeight: kMinInteractiveDimension,
@ -2460,7 +2461,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
child: ConstrainedBox(
constraints:
decoration.suffixIconConstraints ??
themeData.visualDensity.effectiveConstraints(
visualDensity.effectiveConstraints(
const BoxConstraints(
minWidth: kMinInteractiveDimension,
minHeight: kMinInteractiveDimension,
@ -2596,7 +2597,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
alignLabelWithHint: decoration.alignLabelWithHint ?? false,
isDense: decoration.isDense,
isEmpty: isEmpty,
visualDensity: themeData.visualDensity,
visualDensity: visualDensity,
maintainHintSize: maintainHintSize,
icon: icon,
input: input,
@ -2773,6 +2774,7 @@ class InputDecoration {
this.semanticCounterText,
this.alignLabelWithHint,
this.constraints,
this.visualDensity,
}) : assert(
!(label != null && labelText != null),
'Declaring both label and labelText is not supported.',
@ -2880,7 +2882,8 @@ class InputDecoration {
floatingLabelBehavior = floatingLabelBehavior,
// ignore: prefer_initializing_formals, (can't use initializing formals for a deprecated parameter).
floatingLabelAlignment = floatingLabelAlignment,
alignLabelWithHint = false;
alignLabelWithHint = false,
visualDensity = null;
/// An icon to show before the input field and outside of the decoration's
/// container.
@ -3794,6 +3797,32 @@ class InputDecoration {
/// a default height based on text size.
final BoxConstraints? constraints;
/// Defines how compact the decoration's layout will be.
///
/// The vertical aspect of the default or user-specified [contentPadding] is adjusted
/// automatically based on [visualDensity].
///
/// When the visual density is [VisualDensity.compact], the vertical aspect of
/// [contentPadding] is reduced by 8 pixels.
///
/// When the visual density is [VisualDensity.comfortable], the vertical aspect of
/// [contentPadding] is reduced by 4 pixels.
///
/// When the visual density is [VisualDensity.standard] vertical aspect of
/// [contentPadding] is not changed.
///
/// If null, then the ambient [ThemeData.inputDecorationTheme]'s
/// [InputDecorationTheme.visualDensity] will be used. If that is null then
/// [ThemeData.visualDensity] will be used.
///
/// See also:
///
/// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets
/// within a [Theme].
/// * [InputDecorationTheme.visualDensity], which can override this setting for a
/// given decorator.
final VisualDensity? visualDensity;
/// Creates a copy of this input decoration with the given fields replaced
/// by the new values.
InputDecoration copyWith({
@ -3853,6 +3882,7 @@ class InputDecoration {
String? semanticCounterText,
bool? alignLabelWithHint,
BoxConstraints? constraints,
VisualDensity? visualDensity,
}) {
return InputDecoration(
icon: icon ?? this.icon,
@ -3911,6 +3941,7 @@ class InputDecoration {
semanticCounterText: semanticCounterText ?? this.semanticCounterText,
alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint,
constraints: constraints ?? this.constraints,
visualDensity: visualDensity ?? this.visualDensity,
);
}
@ -3955,6 +3986,7 @@ class InputDecoration {
border: border ?? theme.border,
alignLabelWithHint: alignLabelWithHint ?? theme.alignLabelWithHint,
constraints: constraints ?? theme.constraints,
visualDensity: visualDensity ?? theme.visualDensity,
);
}
@ -4022,7 +4054,8 @@ class InputDecoration {
other.enabled == enabled &&
other.semanticCounterText == semanticCounterText &&
other.alignLabelWithHint == alignLabelWithHint &&
other.constraints == constraints;
other.constraints == constraints &&
other.visualDensity == visualDensity;
}
@override
@ -4084,6 +4117,7 @@ class InputDecoration {
semanticCounterText,
alignLabelWithHint,
constraints,
visualDensity,
];
return Object.hashAll(values);
}
@ -4143,6 +4177,7 @@ class InputDecoration {
if (semanticCounterText != null) 'semanticCounterText: $semanticCounterText',
if (alignLabelWithHint != null) 'alignLabelWithHint: $alignLabelWithHint',
if (constraints != null) 'constraints: $constraints',
if (visualDensity != null) 'visualDensity: $visualDensity',
];
return 'InputDecoration(${description.join(', ')})';
}
@ -4198,6 +4233,7 @@ class InputDecorationTheme with Diagnosticable {
this.border,
this.alignLabelWithHint = false,
this.constraints,
this.visualDensity,
});
/// {@macro flutter.material.inputDecoration.labelStyle}
@ -4294,9 +4330,9 @@ class InputDecorationTheme with Diagnosticable {
/// [InputDecoration.helperText], [InputDecoration.errorText], and
/// [InputDecoration.counterText].
///
/// By default the [contentPadding] reflects [isDense] and the type of the
/// [border]. If [isCollapsed] is true then [contentPadding] is
/// [EdgeInsets.zero].
/// By default the [contentPadding] reflects [visualDensity], [isDense] and
/// the type of the [border]. If [isCollapsed] is true then [contentPadding]
/// is [EdgeInsets.zero].
final EdgeInsetsGeometry? contentPadding;
/// Whether the decoration is the same size as the input field.
@ -4612,6 +4648,30 @@ class InputDecorationTheme with Diagnosticable {
/// given decorator.
final BoxConstraints? constraints;
/// Defines how compact the decoration's layout will be.
///
/// The vertical aspect of the default or user-specified [contentPadding] is adjusted
/// automatically based on [visualDensity].
///
/// When the visual density is [VisualDensity.compact], the vertical aspect of
/// [contentPadding] is reduced by 8 pixels.
///
/// When the visual density is [VisualDensity.comfortable], the vertical aspect of
/// [contentPadding] is reduced by 4 pixels.
///
/// When the visual density is [VisualDensity.standard] vertical aspect of
/// [contentPadding] is not changed.
///
/// If null, defaults to [ThemeData.visualDensity].
///
/// See also:
///
/// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets
/// within a [Theme].
/// * [InputDecoration.visualDensity], which can override this setting for a
/// given decorator.
final VisualDensity? visualDensity;
/// Creates a copy of this object but with the given fields replaced with the
/// new values.
InputDecorationTheme copyWith({
@ -4651,6 +4711,7 @@ class InputDecorationTheme with Diagnosticable {
InputBorder? border,
bool? alignLabelWithHint,
BoxConstraints? constraints,
VisualDensity? visualDensity,
}) {
return InputDecorationTheme(
labelStyle: labelStyle ?? this.labelStyle,
@ -4689,6 +4750,7 @@ class InputDecorationTheme with Diagnosticable {
border: border ?? this.border,
alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint,
constraints: constraints ?? this.constraints,
visualDensity: visualDensity ?? this.visualDensity,
);
}
@ -4736,6 +4798,7 @@ class InputDecorationTheme with Diagnosticable {
enabledBorder: enabledBorder ?? inputDecorationTheme.enabledBorder,
border: border ?? inputDecorationTheme.border,
constraints: constraints ?? inputDecorationTheme.constraints,
visualDensity: visualDensity ?? inputDecorationTheme.visualDensity,
);
}
@ -4778,6 +4841,7 @@ class InputDecorationTheme with Diagnosticable {
alignLabelWithHint,
constraints,
hintFadeDuration,
visualDensity,
),
);
@ -4826,7 +4890,8 @@ class InputDecorationTheme with Diagnosticable {
other.hintMaxLines == hintMaxLines &&
other.alignLabelWithHint == alignLabelWithHint &&
other.constraints == constraints &&
other.disabledBorder == disabledBorder;
other.disabledBorder == disabledBorder &&
other.visualDensity == visualDensity;
}
@override
@ -5029,6 +5094,13 @@ class InputDecorationTheme with Diagnosticable {
defaultValue: defaultTheme.constraints,
),
);
properties.add(
DiagnosticsProperty<VisualDensity>(
'visualDensity',
visualDensity,
defaultValue: defaultTheme.visualDensity,
),
);
}
}

View File

@ -728,6 +728,155 @@ void main() {
expect(getContainerRect(tester).height, desktopContainerHeight);
}, variant: TargetPlatformVariant.desktop());
testWidgets(
'default container height is 48dp on all platforms when visual density is VisualDensity.compact',
(WidgetTester tester) async {
// Visual density configured at the decoration level.
await tester.pumpWidget(
buildInputDecorator(
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.compact,
),
),
);
expect(getContainerRect(tester).height, 48.0);
// Visual density configured at the input decoration theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.compact,
),
),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 48.0);
// Visual density configured at the theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(visualDensity: VisualDensity.compact),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 48.0);
},
variant: TargetPlatformVariant.all(),
);
testWidgets(
'default container height is 56dp on all platforms when visual density if VisualDensity.standard',
(WidgetTester tester) async {
// Visual density configured at the decoration level.
await tester.pumpWidget(
buildInputDecorator(
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.standard,
),
),
);
expect(getContainerRect(tester).height, 56.0);
// Visual density configured at the input decoration theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.standard,
),
),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 56.0);
// Visual density configured at the theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(visualDensity: VisualDensity.standard),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 56.0);
},
variant: TargetPlatformVariant.all(),
);
testWidgets('Visual density defined at the decoration level takes precedence', (
WidgetTester tester,
) async {
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
visualDensity: VisualDensity.compact,
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.standard,
),
),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.comfortable,
),
),
);
expect(getContainerRect(tester).height, 52.0);
});
testWidgets('Visual density defined at the input decoration theme level takes precedence', (
WidgetTester tester,
) async {
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
visualDensity: VisualDensity.compact,
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.comfortable,
),
),
decoration: const InputDecoration(
filled: true,
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 52.0);
});
});
group('for outlined text field', () {
@ -1078,6 +1227,155 @@ void main() {
expect(getContainerRect(tester).height, desktopContainerHeight);
}, variant: TargetPlatformVariant.desktop());
testWidgets(
'default container height is 48dp on all platforms when visual density is VisualDensity.compact',
(WidgetTester tester) async {
// Visual density configured at the decoration level.
await tester.pumpWidget(
buildInputDecorator(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.compact,
),
),
);
expect(getContainerRect(tester).height, 48.0);
// Visual density configured at the input decoration theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.compact,
),
),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 48.0);
// Visual density configured at the theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(visualDensity: VisualDensity.compact),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 48.0);
},
variant: TargetPlatformVariant.all(),
);
testWidgets(
'default container height is 56dp on all platforms when visual density if VisualDensity.standard',
(WidgetTester tester) async {
// Visual density configured at the decoration level.
await tester.pumpWidget(
buildInputDecorator(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.standard,
),
),
);
expect(getContainerRect(tester).height, 56.0);
// Visual density configured at the input decoration theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.standard,
),
),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 56.0);
// Visual density configured at the theme level.
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(visualDensity: VisualDensity.standard),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 56.0);
},
variant: TargetPlatformVariant.all(),
);
testWidgets('Visual density defined at the decoration level takes precedence', (
WidgetTester tester,
) async {
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
visualDensity: VisualDensity.compact,
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.standard,
),
),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
visualDensity: VisualDensity.comfortable,
),
),
);
expect(getContainerRect(tester).height, 52.0);
});
testWidgets('Visual density defined at the input decoration theme level takes precedence', (
WidgetTester tester,
) async {
await tester.pumpWidget(
buildInputDecorator(
theme: ThemeData(
visualDensity: VisualDensity.compact,
inputDecorationTheme: const InputDecorationTheme(
visualDensity: VisualDensity.comfortable,
),
),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: labelText,
helperText: helperText,
),
),
);
expect(getContainerRect(tester).height, 52.0);
});
});
testWidgets('InputDecorator with no input border', (WidgetTester tester) async {