[`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(