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

View File

@ -4717,14 +4717,14 @@ void main() {
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,
) async {
final String hintText = 'hint' * 20;
final InputDecoration decoration = InputDecoration(
hintText: hintText,
hintMaxLines: 3,
maintainHintHeight: false,
maintainHintSize: false,
);
await tester.pumpWidget(buildInputDecorator(decoration: decoration));
@ -4741,14 +4741,14 @@ void main() {
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,
) async {
const InputDecoration decoration = InputDecoration(
hintText: hintText,
hintMaxLines: 3,
hintFadeDuration: Duration(milliseconds: 120),
maintainHintHeight: false,
maintainHintSize: false,
);
// Build once with empty content.
@ -4773,14 +4773,14 @@ void main() {
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,
) async {
const InputDecoration decoration = InputDecoration(
hintText: hintText,
hintMaxLines: 3,
hintFadeDuration: Duration(milliseconds: 120),
maintainHintHeight: false,
maintainHintSize: false,
);
// Build once with empty content.
@ -8352,6 +8352,90 @@ void main() {
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', (