Fix TextField intrinsic width when hint is not visible (#161235)

## Description

This PR changes the TextField intrinsic width computation for non-empty
TextField.

Before:

When a TextField is non-empty, the intrinsic width can't be smaller than
the hint width:


![image](https://github.com/user-attachments/assets/88c42a31-d83e-4eb8-8286-dce5b07ef089)


After:

The width does not depend on the hint when the TextField is non empty.


![image](https://github.com/user-attachments/assets/5b7380f8-4bc8-4d37-974e-0c8f5b08b185)


<details><summary>Code sample</summary>

```dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Material App',
      home: Scaffold(
        body: Center(
          child: IntrinsicWidth(
            child: TextField(
              decoration: InputDecoration(
                filled: true,
                hintText: 'Moderately long hint text',
              ),
            ),
          ),
        ),
      ),
    );
  }
}

``` 

</details> 

## Related Issue

Fixes [[TextField] The computation of the intrinsic width of a TextField
should include the hint width only when
visible](https://github.com/flutter/flutter/issues/93337)

## Tests

Adds 2 tests.
This commit is contained in:
Bruno Leroux 2025-01-24 10:34:25 +01:00 committed by GitHub
parent f8b9bdceef
commit a2ec056b82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 156 additions and 17 deletions

View File

@ -563,7 +563,9 @@ class _Decoration {
required this.borderGap, required this.borderGap,
required this.alignLabelWithHint, required this.alignLabelWithHint,
required this.isDense, required this.isDense,
required this.isEmpty,
required this.visualDensity, required this.visualDensity,
required this.maintainHintSize,
this.icon, this.icon,
this.input, this.input,
this.label, this.label,
@ -586,7 +588,9 @@ class _Decoration {
final _InputBorderGap borderGap; final _InputBorderGap borderGap;
final bool alignLabelWithHint; final bool alignLabelWithHint;
final bool? isDense; final bool? isDense;
final bool isEmpty;
final VisualDensity visualDensity; final VisualDensity visualDensity;
final bool maintainHintSize;
final Widget? icon; final Widget? icon;
final Widget? input; final Widget? input;
final Widget? label; final Widget? label;
@ -617,7 +621,9 @@ class _Decoration {
other.borderGap == borderGap && other.borderGap == borderGap &&
other.alignLabelWithHint == alignLabelWithHint && other.alignLabelWithHint == alignLabelWithHint &&
other.isDense == isDense && other.isDense == isDense &&
other.isEmpty == isEmpty &&
other.visualDensity == visualDensity && other.visualDensity == visualDensity &&
other.maintainHintSize == maintainHintSize &&
other.icon == icon && other.icon == icon &&
other.input == input && other.input == input &&
other.label == label && other.label == label &&
@ -641,7 +647,9 @@ class _Decoration {
borderGap, borderGap,
alignLabelWithHint, alignLabelWithHint,
isDense, isDense,
isEmpty,
visualDensity, visualDensity,
maintainHintSize,
icon, icon,
input, input,
label, label,
@ -650,9 +658,7 @@ class _Decoration {
suffix, suffix,
prefixIcon, prefixIcon,
suffixIcon, suffixIcon,
helperError, Object.hash(helperError, counter, container),
counter,
container,
); );
} }
@ -1021,8 +1027,11 @@ class _RenderDecoration extends RenderBox
final double hintBaseline = final double hintBaseline =
hint == null ? 0.0 : getBaseline(hint, boxConstraints.tighten(width: inputWidth)); hint == null ? 0.0 : getBaseline(hint, boxConstraints.tighten(width: inputWidth));
// The field can be occupied by a hint or by the input itself // The field can be occupied by a hint or by the input itself.
final double inputHeight = math.max(hintSize.height, inputSize.height); final double inputHeight = math.max(
decoration.isEmpty || decoration.maintainHintSize ? hintSize.height : 0.0,
inputSize.height,
);
final double inputInternalBaseline = math.max(inputBaseline, hintBaseline); final double inputInternalBaseline = math.max(inputBaseline, hintBaseline);
final double prefixBaseline = prefix == null ? 0.0 : getBaseline(prefix, contentConstraints); final double prefixBaseline = prefix == null ? 0.0 : getBaseline(prefix, contentConstraints);
@ -1154,11 +1163,15 @@ class _RenderDecoration extends RenderBox
@override @override
double computeMinIntrinsicWidth(double height) { double computeMinIntrinsicWidth(double height) {
final double contentWidth =
decoration.isEmpty || decoration.maintainHintSize
? math.max(_minWidth(input, height), _minWidth(hint, height))
: _minWidth(input, height);
return _minWidth(icon, height) + return _minWidth(icon, height) +
(prefixIcon != null ? prefixToInputGap : contentPadding.start) + (prefixIcon != null ? prefixToInputGap : contentPadding.start) +
_minWidth(prefixIcon, height) + _minWidth(prefixIcon, height) +
_minWidth(prefix, height) + _minWidth(prefix, height) +
math.max(_minWidth(input, height), _minWidth(hint, height)) + contentWidth +
_minWidth(suffix, height) + _minWidth(suffix, height) +
_minWidth(suffixIcon, height) + _minWidth(suffixIcon, height) +
(suffixIcon != null ? inputToSuffixGap : contentPadding.end); (suffixIcon != null ? inputToSuffixGap : contentPadding.end);
@ -1166,11 +1179,15 @@ class _RenderDecoration extends RenderBox
@override @override
double computeMaxIntrinsicWidth(double height) { double computeMaxIntrinsicWidth(double height) {
final double contentWidth =
decoration.isEmpty || decoration.maintainHintSize
? math.max(_maxWidth(input, height), _maxWidth(hint, height))
: _maxWidth(input, height);
return _maxWidth(icon, height) + return _maxWidth(icon, height) +
(prefixIcon != null ? prefixToInputGap : contentPadding.start) + (prefixIcon != null ? prefixToInputGap : contentPadding.start) +
_maxWidth(prefixIcon, height) + _maxWidth(prefixIcon, height) +
_maxWidth(prefix, height) + _maxWidth(prefix, height) +
math.max(_maxWidth(input, height), _maxWidth(hint, height)) + contentWidth +
_maxWidth(suffix, height) + _maxWidth(suffix, height) +
_maxWidth(suffixIcon, height) + _maxWidth(suffixIcon, height) +
(suffixIcon != null ? inputToSuffixGap : contentPadding.end); (suffixIcon != null ? inputToSuffixGap : contentPadding.end);
@ -1227,7 +1244,10 @@ class _RenderDecoration extends RenderBox
width - prefixWidth - suffixWidth - prefixIconWidth - suffixIconWidth, width - prefixWidth - suffixWidth - prefixIconWidth - suffixIconWidth,
0.0, 0.0,
); );
final double inputHeight = _lineHeight(availableInputWidth, <RenderBox?>[input, hint]); final double inputHeight = _lineHeight(availableInputWidth, <RenderBox?>[
input,
if (decoration.isEmpty) hint,
]);
final double inputMaxHeight = <double>[ final double inputMaxHeight = <double>[
inputHeight, inputHeight,
prefixHeight, prefixHeight,
@ -1567,7 +1587,9 @@ class _RenderDecoration extends RenderBox
doPaint(suffix); doPaint(suffix);
doPaint(prefixIcon); doPaint(prefixIcon);
doPaint(suffixIcon); doPaint(suffixIcon);
if (decoration.isEmpty) {
doPaint(hint); doPaint(hint);
}
doPaint(input); doPaint(input);
doPaint(helperError); doPaint(helperError);
doPaint(counter); doPaint(counter);
@ -2247,7 +2269,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
final TextStyle hintStyle = _getInlineHintStyle(themeData, defaults); final TextStyle hintStyle = _getInlineHintStyle(themeData, defaults);
final String? hintText = decoration.hintText; final String? hintText = decoration.hintText;
final bool maintainHintHeight = decoration.maintainHintHeight; final bool maintainHintSize = decoration.maintainHintSize;
Widget? hint; Widget? hint;
if (decoration.hint != null || hintText != null) { if (decoration.hint != null || hintText != null) {
final Widget hintWidget = final Widget hintWidget =
@ -2264,7 +2286,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
); );
final bool showHint = isEmpty && !_hasInlineLabel; final bool showHint = isEmpty && !_hasInlineLabel;
hint = hint =
maintainHintHeight maintainHintSize
? AnimatedOpacity( ? AnimatedOpacity(
opacity: showHint ? 1.0 : 0.0, opacity: showHint ? 1.0 : 0.0,
duration: decoration.hintFadeDuration ?? _kHintFadeTransitionDuration, duration: decoration.hintFadeDuration ?? _kHintFadeTransitionDuration,
@ -2573,7 +2595,9 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
borderGap: _borderGap, borderGap: _borderGap,
alignLabelWithHint: decoration.alignLabelWithHint ?? false, alignLabelWithHint: decoration.alignLabelWithHint ?? false,
isDense: decoration.isDense, isDense: decoration.isDense,
isEmpty: isEmpty,
visualDensity: themeData.visualDensity, visualDensity: themeData.visualDensity,
maintainHintSize: maintainHintSize,
icon: icon, icon: icon,
input: input, input: input,
label: label, label: label,
@ -2704,7 +2728,13 @@ class InputDecoration {
this.hintTextDirection, this.hintTextDirection,
this.hintMaxLines, this.hintMaxLines,
this.hintFadeDuration, this.hintFadeDuration,
@Deprecated(
'Use maintainHintSize instead. '
'This will maintain both hint height and hint width. '
'This feature was deprecated after v3.28.0-2.0.pre.',
)
this.maintainHintHeight = true, this.maintainHintHeight = true,
this.maintainHintSize = true,
this.error, this.error,
this.errorText, this.errorText,
this.errorStyle, this.errorStyle,
@ -2794,7 +2824,13 @@ class InputDecoration {
this.hintTextDirection, this.hintTextDirection,
this.hintMaxLines, this.hintMaxLines,
this.hintFadeDuration, this.hintFadeDuration,
@Deprecated(
'Use maintainHintSize instead. '
'This will maintain both hint height and hint width. '
'This feature was deprecated after v3.28.0-2.0.pre.',
)
this.maintainHintHeight = true, this.maintainHintHeight = true,
this.maintainHintSize = true,
this.filled = false, this.filled = false,
this.fillColor, this.fillColor,
this.focusColor, this.focusColor,
@ -3076,8 +3112,22 @@ class InputDecoration {
/// it's not visible, if this flag is set to false. /// it's not visible, if this flag is set to false.
/// ///
/// Defaults to true. /// Defaults to true.
@Deprecated(
'Use maintainHintSize instead. '
'This will maintain both hint height and hint width. '
'This feature was deprecated after v3.28.0-2.0.pre.',
)
final bool maintainHintHeight; final bool maintainHintHeight;
/// Whether the input field's size should always be greater than or equal to
/// the size of the [hintText], even if the [hintText] is not visible.
///
/// The [InputDecorator] widget ignores [hintText] during layout when
/// it's not visible, if this flag is set to false.
///
/// Defaults to true.
final bool maintainHintSize;
/// Optional widget that appears below the [InputDecorator.child] and the border. /// Optional widget that appears below the [InputDecorator.child] and the border.
/// ///
/// If non-null, the border's color animates to red and the [helperText] is not shown. /// If non-null, the border's color animates to red and the [helperText] is not shown.
@ -3764,6 +3814,7 @@ class InputDecoration {
Duration? hintFadeDuration, Duration? hintFadeDuration,
int? hintMaxLines, int? hintMaxLines,
bool? maintainHintHeight, bool? maintainHintHeight,
bool? maintainHintSize,
Widget? error, Widget? error,
String? errorText, String? errorText,
TextStyle? errorStyle, TextStyle? errorStyle,
@ -3821,6 +3872,7 @@ class InputDecoration {
hintMaxLines: hintMaxLines ?? this.hintMaxLines, hintMaxLines: hintMaxLines ?? this.hintMaxLines,
hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration, hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration,
maintainHintHeight: maintainHintHeight ?? this.maintainHintHeight, maintainHintHeight: maintainHintHeight ?? this.maintainHintHeight,
maintainHintSize: maintainHintSize ?? this.maintainHintSize,
error: error ?? this.error, error: error ?? this.error,
errorText: errorText ?? this.errorText, errorText: errorText ?? this.errorText,
errorStyle: errorStyle ?? this.errorStyle, errorStyle: errorStyle ?? this.errorStyle,
@ -3931,6 +3983,7 @@ class InputDecoration {
other.hintMaxLines == hintMaxLines && other.hintMaxLines == hintMaxLines &&
other.hintFadeDuration == hintFadeDuration && other.hintFadeDuration == hintFadeDuration &&
other.maintainHintHeight == maintainHintHeight && other.maintainHintHeight == maintainHintHeight &&
other.maintainHintSize == maintainHintSize &&
other.error == error && other.error == error &&
other.errorText == errorText && other.errorText == errorText &&
other.errorStyle == errorStyle && other.errorStyle == errorStyle &&
@ -3991,6 +4044,7 @@ class InputDecoration {
hintMaxLines, hintMaxLines,
hintFadeDuration, hintFadeDuration,
maintainHintHeight, maintainHintHeight,
maintainHintSize,
error, error,
errorText, errorText,
errorStyle, errorStyle,
@ -4049,6 +4103,7 @@ class InputDecoration {
if (hintMaxLines != null) 'hintMaxLines: "$hintMaxLines"', if (hintMaxLines != null) 'hintMaxLines: "$hintMaxLines"',
if (hintFadeDuration != null) 'hintFadeDuration: "$hintFadeDuration"', if (hintFadeDuration != null) 'hintFadeDuration: "$hintFadeDuration"',
if (!maintainHintHeight) 'maintainHintHeight: false', if (!maintainHintHeight) 'maintainHintHeight: false',
if (!maintainHintSize) 'maintainHintSize: false',
if (error != null) 'error: "$error"', if (error != null) 'error: "$error"',
if (errorText != null) 'errorText: "$errorText"', if (errorText != null) 'errorText: "$errorText"',
if (errorStyle != null) 'errorStyle: "$errorStyle"', if (errorStyle != null) 'errorStyle: "$errorStyle"',

View File

@ -4717,14 +4717,14 @@ void main() {
expect(hintTextWidget.style!.overflow, decoration.hintStyle!.overflow); expect(hintTextWidget.style!.overflow, decoration.hintStyle!.overflow);
}); });
testWidgets('Widget height collapses from hint height when maintainHintHeight is false', ( testWidgets('Widget height collapses from hint height when maintainHintSize is false', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
final String hintText = 'hint' * 20; final String hintText = 'hint' * 20;
final InputDecoration decoration = InputDecoration( final InputDecoration decoration = InputDecoration(
hintText: hintText, hintText: hintText,
hintMaxLines: 3, hintMaxLines: 3,
maintainHintHeight: false, maintainHintSize: false,
); );
await tester.pumpWidget(buildInputDecorator(decoration: decoration)); await tester.pumpWidget(buildInputDecorator(decoration: decoration));
@ -4741,14 +4741,14 @@ void main() {
expect(inputHeight, hintHeight + 16.0); expect(inputHeight, hintHeight + 16.0);
}); });
testWidgets('hintFadeDuration applies to hint fade-in when maintainHintHeight is false', ( testWidgets('hintFadeDuration applies to hint fade-in when maintainHintSize is false', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
const InputDecoration decoration = InputDecoration( const InputDecoration decoration = InputDecoration(
hintText: hintText, hintText: hintText,
hintMaxLines: 3, hintMaxLines: 3,
hintFadeDuration: Duration(milliseconds: 120), hintFadeDuration: Duration(milliseconds: 120),
maintainHintHeight: false, maintainHintSize: false,
); );
// Build once with empty content. // Build once with empty content.
@ -4773,14 +4773,14 @@ void main() {
expect(hintOpacity120ms, 1.0); expect(hintOpacity120ms, 1.0);
}); });
testWidgets('hintFadeDuration applies to hint fade-out when maintainHintHeight is false', ( testWidgets('hintFadeDuration applies to hint fade-out when maintainHintSize is false', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
const InputDecoration decoration = InputDecoration( const InputDecoration decoration = InputDecoration(
hintText: hintText, hintText: hintText,
hintMaxLines: 3, hintMaxLines: 3,
hintFadeDuration: Duration(milliseconds: 120), hintFadeDuration: Duration(milliseconds: 120),
maintainHintHeight: false, maintainHintSize: false,
); );
// Build once with empty content. // Build once with empty content.
@ -8352,6 +8352,90 @@ void main() {
expect(textSizeWithIcons.width, equals(textSizeWithoutIcon.width)); expect(textSizeWithIcons.width, equals(textSizeWithoutIcon.width));
}); });
testWidgets('depends on hint width and content width when decorator is empty', (
WidgetTester tester,
) async {
const InputDecoration decorationWithHint = InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'Hint',
);
const double hintTextWidth = 66.0;
const double smallContentWidth = 20.0;
const double largeContentWidth = 80.0;
await tester.pumpWidget(
buildInputDecorator(
decoration: decorationWithHint,
useIntrinsicWidth: true,
isEmpty: true,
child: const SizedBox(width: smallContentWidth),
),
);
// Decorator width depends on the hint because the hint is larger than the content.
expect(getDecoratorRect(tester).width, hintTextWidth);
await tester.pumpWidget(
buildInputDecorator(
decoration: decorationWithHint,
useIntrinsicWidth: true,
isEmpty: true,
child: const SizedBox(width: largeContentWidth),
),
);
// Decorator width depends on the content because the content is larger than the hint.
expect(getDecoratorRect(tester).width, largeContentWidth);
});
// Regression test for https://github.com/flutter/flutter/issues/93337.
testWidgets(
'depends on content width when decorator is not empty and maintainHintSize is false',
(WidgetTester tester) async {
const InputDecoration decorationWithHint = InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'Hint',
maintainHintSize: false,
);
const double contentWidth = 20.0;
await tester.pumpWidget(
buildInputDecorator(
decoration: decorationWithHint,
useIntrinsicWidth: true,
child: const SizedBox(width: contentWidth),
),
);
// The hint width is ignored even if larger than the content width.
expect(getDecoratorRect(tester).width, contentWidth);
},
);
// Regression test for https://github.com/flutter/flutter/issues/93337.
testWidgets(
'depends on content width when decorator is not empty and maintainHintSize is true',
(WidgetTester tester) async {
const InputDecoration decorationWithHint = InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'Hint',
);
const double contentWidth = 20.0;
await tester.pumpWidget(
buildInputDecorator(
decoration: decorationWithHint,
useIntrinsicWidth: true,
child: const SizedBox(width: contentWidth),
),
);
// The hint width is ignored even if larger than the content width.
const double hintTextWidth = 66.0;
expect(getDecoratorRect(tester).width, hintTextWidth);
},
);
}); });
testWidgets('Ensure the height of labelStyle remains unchanged when TextField is focused', ( testWidgets('Ensure the height of labelStyle remains unchanged when TextField is focused', (