[`RenderEditable`] report real height when `maxLines == 1`. (#112029)
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index 0c893ec..be6be2d 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1154,7 +1154,7 @@
/// Valid only after [layout] has been called.
List<ui.LineMetrics> computeLineMetrics() {
assert(_debugAssertTextLayoutIsValid);
- return _lineMetricsCache ??= _paragraph!.computeLineMetrics();
+ return _lineMetricsCache ??= _paragraph!.computeLineMetrics();
}
bool _disposed = false;
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 0283f9e..fa34fe0 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -318,6 +318,7 @@
textDirection: textDirection,
textScaleFactor: textScaleFactor,
locale: locale,
+ maxLines: maxLines == 1 ? 1 : null,
strutStyle: strutStyle,
textHeightBehavior: textHeightBehavior,
textWidthBasis: textWidthBasis,
@@ -781,8 +782,7 @@
// Returns the obscured text when [obscureText] is true. See
// [obscureText] and [obscuringCharacter].
String get _plainText {
- _cachedPlainText ??= _textPainter.text!.toPlainText(includeSemanticsLabels: false);
- return _cachedPlainText!;
+ return _cachedPlainText ??= _textPainter.text!.toPlainText(includeSemanticsLabels: false);
}
/// The text to display.
@@ -794,8 +794,10 @@
if (_textPainter.text == value) {
return;
}
- _textPainter.text = value;
_cachedPlainText = null;
+ _cachedLineBreakCount = null;
+
+ _textPainter.text = value;
_cachedAttributedValue = null;
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
@@ -965,6 +967,11 @@
return;
}
_maxLines = value;
+
+ // Special case maxLines == 1 to keep only the first line so we can get the
+ // height of the first line in case there are hard line breaks in the text.
+ // See the `_preferredHeight` method.
+ _textPainter.maxLines = value == 1 ? 1 : null;
markNeedsTextLayout();
}
@@ -1790,42 +1797,72 @@
/// This does not require the layout to be updated.
double get preferredLineHeight => _textPainter.preferredLineHeight;
+ int? _cachedLineBreakCount;
+ // TODO(LongCatIsLooong): see if we can let ui.Paragraph estimate the number
+ // of lines
+ int _countHardLineBreaks(String text) {
+ final int? cachedValue = _cachedLineBreakCount;
+ if (cachedValue != null) {
+ return cachedValue;
+ }
+ int count = 0;
+ for (int index = 0; index < text.length; index += 1) {
+ switch (text.codeUnitAt(index)) {
+ case 0x000A: // LF
+ case 0x0085: // NEL
+ case 0x000B: // VT
+ case 0x000C: // FF, treating it as a regular line separator
+ case 0x2028: // LS
+ case 0x2029: // PS
+ count += 1;
+ }
+ }
+ return _cachedLineBreakCount = count;
+ }
+
double _preferredHeight(double width) {
- // Lock height to maxLines if needed.
- final bool lockedMax = maxLines != null && minLines == null;
- final bool lockedBoth = minLines != null && minLines == maxLines;
- final bool singleLine = maxLines == 1;
- if (singleLine || lockedMax || lockedBoth) {
- return preferredLineHeight * maxLines!;
- }
+ final int? maxLines = this.maxLines;
+ final int? minLines = this.minLines ?? maxLines;
+ final double minHeight = preferredLineHeight * (minLines ?? 0);
- // Clamp height to minLines or maxLines if needed.
- final bool minLimited = minLines != null && minLines! > 1;
- final bool maxLimited = maxLines != null;
- if (minLimited || maxLimited) {
+ if (maxLines == null) {
+ final double estimatedHeight;
+ if (width == double.infinity) {
+ estimatedHeight = preferredLineHeight * (_countHardLineBreaks(_plainText) + 1);
+ } else {
+ _layoutText(maxWidth: width);
+ estimatedHeight = _textPainter.height;
+ }
+ return math.max(estimatedHeight, minHeight);
+ }
+ // TODO(LongCatIsLooong): this is a workaround for
+ // https://github.com/flutter/flutter/issues/112123 .
+ // Use preferredLineHeight since SkParagraph currently returns an incorrect
+ // height.
+ final TextHeightBehavior? textHeightBehavior = this.textHeightBehavior;
+ final bool usePreferredLineHeightHack = maxLines == 1
+ && text?.codeUnitAt(0) == null
+ && strutStyle != null && strutStyle != StrutStyle.disabled
+ && textHeightBehavior != null
+ && (!textHeightBehavior.applyHeightToFirstAscent || !textHeightBehavior.applyHeightToLastDescent);
+
+ // Special case maxLines == 1 since it forces the scrollable direction
+ // to be horizontal. Report the real height to prevent the text from being
+ // clipped.
+ if (maxLines == 1 && !usePreferredLineHeightHack) {
+ // The _layoutText call lays out the paragraph using infinite width when
+ // maxLines == 1. Also _textPainter.maxLines will be set to 1 so should
+ // there be any line breaks only the first line is shown.
+ assert(_textPainter.maxLines == 1);
_layoutText(maxWidth: width);
- if (minLimited && _textPainter.height < preferredLineHeight * minLines!) {
- return preferredLineHeight * minLines!;
- }
- if (maxLimited && _textPainter.height > preferredLineHeight * maxLines!) {
- return preferredLineHeight * maxLines!;
- }
+ return _textPainter.height;
}
-
- // Set the height based on the content.
- if (width == double.infinity) {
- final String text = _plainText;
- int lines = 1;
- for (int index = 0; index < text.length; index += 1) {
- // Count explicit line breaks.
- if (text.codeUnitAt(index) == 0x0A) {
- lines += 1;
- }
- }
- return preferredLineHeight * lines;
+ if (minLines == maxLines) {
+ return minHeight;
}
_layoutText(maxWidth: width);
- return math.max(preferredLineHeight, _textPainter.height);
+ final double maxHeight = preferredLineHeight * maxLines;
+ return clampDouble(_textPainter.height, minHeight, maxHeight);
}
@override
@@ -1852,14 +1889,17 @@
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Hit test text spans.
bool hitText = false;
- final Offset effectivePosition = position - _paintOffset;
- final TextPosition textPosition = _textPainter.getPositionForOffset(effectivePosition);
- final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition);
- if (span != null && span is HitTestTarget) {
- result.add(HitTestEntry(span as HitTestTarget));
- hitText = true;
- }
+ final InlineSpan? textSpan = _textPainter.text;
+ if (textSpan != null) {
+ final Offset effectivePosition = position - _paintOffset;
+ final TextPosition textPosition = _textPainter.getPositionForOffset(effectivePosition);
+ final Object? span = textSpan.getSpanForPosition(textPosition);
+ if (span is HitTestTarget) {
+ result.add(HitTestEntry(span));
+ hitText = true;
+ }
+ }
// Hit test render object children
RenderBox? child = firstChild;
int childIndex = 0;
@@ -2359,7 +2399,8 @@
final Size textPainterSize = _textPainter.size;
final double width = forceLine ? constraints.maxWidth : constraints
.constrainWidth(_textPainter.size.width + _caretMargin);
- size = Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
+ final double preferredHeight = _preferredHeight(constraints.maxWidth);
+ size = Size(width, constraints.constrainHeight(preferredHeight));
final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height);
final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize);
@@ -2595,8 +2636,9 @@
_clipRectLayer.layer = null;
_paintContents(context, offset);
}
- if (selection!.isValid) {
- _paintHandleLayers(context, getEndpointsForSelection(selection!), offset);
+ final TextSelection? selection = this.selection;
+ if (selection != null && selection.isValid) {
+ _paintHandleLayers(context, getEndpointsForSelection(selection), offset);
}
}
diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart
index 400b52b..09e675c 100644
--- a/packages/flutter/lib/src/rendering/shifted_box.dart
+++ b/packages/flutter/lib/src/rendering/shifted_box.dart
@@ -31,43 +31,32 @@
@override
double computeMinIntrinsicWidth(double height) {
- if (child != null) {
- return child!.getMinIntrinsicWidth(height);
- }
- return 0.0;
+ return child?.getMinIntrinsicWidth(height) ?? 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
- if (child != null) {
- return child!.getMaxIntrinsicWidth(height);
- }
- return 0.0;
+ return child?.getMaxIntrinsicWidth(height) ?? 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
- if (child != null) {
- return child!.getMinIntrinsicHeight(width);
- }
- return 0.0;
+ return child?.getMinIntrinsicHeight(width) ?? 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
- if (child != null) {
- return child!.getMaxIntrinsicHeight(width);
- }
- return 0.0;
+ return child?.getMaxIntrinsicHeight(width) ?? 0.0;
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
double? result;
+ final RenderBox? child = this.child;
if (child != null) {
assert(!debugNeedsLayout);
- result = child!.getDistanceToActualBaseline(baseline);
- final BoxParentData childParentData = child!.parentData! as BoxParentData;
+ result = child.getDistanceToActualBaseline(baseline);
+ final BoxParentData childParentData = child.parentData! as BoxParentData;
if (result != null) {
result += childParentData.offset.dy;
}
@@ -79,22 +68,24 @@
@override
void paint(PaintingContext context, Offset offset) {
+ final RenderBox? child = this.child;
if (child != null) {
- final BoxParentData childParentData = child!.parentData! as BoxParentData;
- context.paintChild(child!, childParentData.offset + offset);
+ final BoxParentData childParentData = child.parentData! as BoxParentData;
+ context.paintChild(child, childParentData.offset + offset);
}
}
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
+ final RenderBox? child = this.child;
if (child != null) {
- final BoxParentData childParentData = child!.parentData! as BoxParentData;
+ final BoxParentData childParentData = child.parentData! as BoxParentData;
return result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
- return child!.hitTest(result, position: transformed);
+ return child.hitTest(result, position: transformed);
},
);
}
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 1b000e0..d8e0a62 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -1139,8 +1139,8 @@
/// [TextEditingController.addListener].
///
/// [onChanged] is called before [onSubmitted] when user indicates completion
- /// of editing, such as when pressing the "done" button on the keyboard. That default
- /// behavior can be overridden. See [onEditingComplete] for details.
+ /// of editing, such as when pressing the "done" button on the keyboard. That
+ /// default behavior can be overridden. See [onEditingComplete] for details.
///
/// {@tool dartpad}
/// This example shows how onChanged could be used to check the TextField's
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index 57a512d..d719758 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -5149,9 +5149,13 @@
height: 200.0,
width: 200.0,
child: Center(
- child: CupertinoTextField(
- controller: OverflowWidgetTextEditingController(),
- clipBehavior: Clip.none,
+ child: SizedBox(
+ // Make sure the input field is not high enough for the WidgetSpan.
+ height: 50,
+ child: CupertinoTextField(
+ controller: OverflowWidgetTextEditingController(),
+ clipBehavior: Clip.none,
+ ),
),
),
),
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index e3afd17..97242ac 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -841,9 +841,13 @@
height: 200,
width: 200,
child: Center(
- child: TextField(
- controller: OverflowWidgetTextEditingController(),
- clipBehavior: Clip.none,
+ child: SizedBox(
+ // Make sure the input field is not high enough for the WidgetSpan.
+ height: 50,
+ child: TextField(
+ controller: OverflowWidgetTextEditingController(),
+ clipBehavior: Clip.none,
+ ),
),
),
),
@@ -9068,6 +9072,7 @@
home: Material(
child: Center(
child: TextField(
+ maxLines: null,
controller: controller,
),
),
diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart
index dce4da9..e7b797f 100644
--- a/packages/flutter/test/rendering/editable_test.dart
+++ b/packages/flutter/test/rendering/editable_test.dart
@@ -95,6 +95,44 @@
}
});
+ test('Reports the real height when maxLines is 1', () {
+ const InlineSpan tallSpan = TextSpan(
+ style: TextStyle(fontSize: 10),
+ children: <InlineSpan>[TextSpan(text: 'TALL', style: TextStyle(fontSize: 100))],
+ );
+ final BoxConstraints constraints = BoxConstraints.loose(const Size(600, 600));
+ final RenderEditable editable = RenderEditable(
+ textDirection: TextDirection.ltr,
+ startHandleLayerLink: LayerLink(),
+ endHandleLayerLink: LayerLink(),
+ offset: ViewportOffset.zero(),
+ textSelectionDelegate: _FakeEditableTextState(),
+ text: tallSpan,
+ );
+
+ layout(editable, constraints: constraints);
+ expect(editable.size.height, 100);
+ });
+
+ test('Reports the height of the first line when maxLines is 1', () {
+ final InlineSpan multilineSpan = TextSpan(
+ text: 'liiiiines\n' * 10,
+ style: const TextStyle(fontSize: 10),
+ );
+ final BoxConstraints constraints = BoxConstraints.loose(const Size(600, 600));
+ final RenderEditable editable = RenderEditable(
+ textDirection: TextDirection.ltr,
+ startHandleLayerLink: LayerLink(),
+ endHandleLayerLink: LayerLink(),
+ offset: ViewportOffset.zero(),
+ textSelectionDelegate: _FakeEditableTextState(),
+ text: multilineSpan,
+ );
+
+ layout(editable, constraints: constraints);
+ expect(editable.size.height, 10);
+ });
+
test('Editable respect clipBehavior in describeApproximatePaintClip', () {
final String longString = 'a' * 10000;
final RenderEditable editable = RenderEditable(