From e70a1d1d7a1a8ff1b5afbcfd505809f0c305c61e Mon Sep 17 00:00:00 2001 From: Gary Qian Date: Wed, 9 Jun 2021 21:54:02 -0700 Subject: [PATCH] Support WidgetSpan in RenderEditable (#83537) --- .../flutter/lib/src/rendering/editable.dart | 343 ++++++++++++--- .../flutter/lib/src/rendering/paragraph.dart | 18 +- .../lib/src/widgets/editable_text.dart | 35 +- .../test/material/text_field_test.dart | 6 +- .../flutter/test/rendering/editable_test.dart | 389 +++++++++++++++++- .../test/widgets/editable_text_test.dart | 16 +- 6 files changed, 704 insertions(+), 103 deletions(-) diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index e3036b33fa..671d44a75f 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -4,7 +4,7 @@ import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle; +import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, PlaceholderAlignment; import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; @@ -12,10 +12,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; +import 'package:vector_math/vector_math_64.dart'; + import 'box.dart'; import 'custom_paint.dart'; import 'layer.dart'; import 'object.dart'; +import 'paragraph.dart'; import 'viewport_offset.dart'; const double _kCaretGap = 1.0; // pixels @@ -136,7 +139,7 @@ bool _isWhitespace(int codeUnit) { /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value /// to actually blink the cursor, and other features not mentioned above are the /// responsibility of higher layers and not handled by this object. -class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { +class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { /// Creates a render object that implements the visual aspects of a text field. /// /// The [textAlign] argument must not be null. It defaults to [TextAlign.start]. @@ -152,7 +155,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// The [offset] is required and must not be null. You can use [new /// ViewportOffset.zero] if you have no need for scrolling. RenderEditable({ - TextSpan? text, + InlineSpan? text, required TextDirection textDirection, TextAlign textAlign = TextAlign.start, Color? cursorColor, @@ -199,6 +202,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { required this.textSelectionDelegate, RenderEditablePainter? painter, RenderEditablePainter? foregroundPainter, + List? children, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), assert(maxLines == null || maxLines > 0), @@ -277,6 +281,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _updateForegroundPainter(foregroundPainter); _updatePainter(painter); + addAll(children); + _extractPlaceholderSpans(text); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TextParentData) + child.parentData = TextParentData(); } /// Child render objects @@ -310,6 +322,17 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _foregroundPainter = newPainter; } + late List _placeholderSpans; + void _extractPlaceholderSpans(InlineSpan? span) { + _placeholderSpans = []; + span?.visitChildren((InlineSpan span) { + if (span is PlaceholderSpan) { + _placeholderSpans.add(span); + } + return true; + }); + } + /// The [RenderEditablePainter] to use for painting above this /// [RenderEditable]'s text content. /// @@ -2295,13 +2318,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } /// The text to display. - TextSpan? get text => _textPainter.text as TextSpan?; + InlineSpan? get text => _textPainter.text; final TextPainter _textPainter; - set text(TextSpan? value) { + set text(InlineSpan? value) { if (_textPainter.text == value) return; _textPainter.text = value; _cachedPlainText = null; + _extractPlaceholderSpans(value); markNeedsTextLayout(); markNeedsSemanticsUpdate(); } @@ -2831,74 +2855,96 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { Rect currentRect; double ordinal = 0.0; int start = 0; + int placeholderIndex = 0; + int childIndex = 0; + RenderBox? child = firstChild; final Queue newChildCache = Queue(); for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) { - assert(!info.isPlaceholder); final TextSelection selection = TextSelection( baseOffset: start, extentOffset: start + info.text.length, ); start += info.text.length; - final TextDirection initialDirection = currentDirection; - final List rects = _textPainter.getBoxesForSelection(selection); - if (rects.isEmpty) { - continue; - } - Rect rect = rects.first.toRect(); - currentDirection = rects.first.direction; - for (final ui.TextBox textBox in rects.skip(1)) { - rect = rect.expandToInclude(textBox.toRect()); - currentDirection = textBox.direction; - } - // Any of the text boxes may have had infinite dimensions. - // We shouldn't pass infinite dimensions up to the bridges. - rect = Rect.fromLTWH( - math.max(0.0, rect.left), - math.max(0.0, rect.top), - math.min(rect.width, constraints.maxWidth), - math.min(rect.height, constraints.maxHeight), - ); - // Round the current rectangle to make this API testable and add some - // padding so that the accessibility rects do not overlap with the text. - currentRect = Rect.fromLTRB( - rect.left.floorToDouble() - 4.0, - rect.top.floorToDouble() - 4.0, - rect.right.ceilToDouble() + 4.0, - rect.bottom.ceilToDouble() + 4.0, - ); - final SemanticsConfiguration configuration = SemanticsConfiguration() - ..sortKey = OrdinalSortKey(ordinal++) - ..textDirection = initialDirection - ..label = info.semanticsLabel ?? info.text; - final GestureRecognizer? recognizer = info.recognizer; - if (recognizer != null) { - if (recognizer is TapGestureRecognizer) { - if (recognizer.onTap != null) { - configuration.onTap = recognizer.onTap; - configuration.isLink = true; - } - } else if (recognizer is DoubleTapGestureRecognizer) { - if (recognizer.onDoubleTap != null) { - configuration.onTap = recognizer.onDoubleTap; - configuration.isLink = true; - } - } else if (recognizer is LongPressGestureRecognizer) { - if (recognizer.onLongPress != null) { - configuration.onLongPress = recognizer.onLongPress; - } - } else { - assert(false, '${recognizer.runtimeType} is not supported.'); + if (info.isPlaceholder) { + // A placeholder span may have 0 to multiple semantics nodes, we need + // to annotate all of the semantics nodes belong to this span. + while (children.length > childIndex && + children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + final SemanticsNode childNode = children.elementAt(childIndex); + final TextParentData parentData = child!.parentData! as TextParentData; + childNode.rect = Rect.fromLTWH( + childNode.rect.left, + childNode.rect.top, + childNode.rect.width * parentData.scale!, + childNode.rect.height * parentData.scale!, + ); + newChildren.add(childNode); + childIndex += 1; } + child = childAfter(child!); + placeholderIndex += 1; + } else { + final TextDirection initialDirection = currentDirection; + final List rects = _textPainter.getBoxesForSelection(selection); + if (rects.isEmpty) { + continue; + } + Rect rect = rects.first.toRect(); + currentDirection = rects.first.direction; + for (final ui.TextBox textBox in rects.skip(1)) { + rect = rect.expandToInclude(textBox.toRect()); + currentDirection = textBox.direction; + } + // Any of the text boxes may have had infinite dimensions. + // We shouldn't pass infinite dimensions up to the bridges. + rect = Rect.fromLTWH( + math.max(0.0, rect.left), + math.max(0.0, rect.top), + math.min(rect.width, constraints.maxWidth), + math.min(rect.height, constraints.maxHeight), + ); + // Round the current rectangle to make this API testable and add some + // padding so that the accessibility rects do not overlap with the text. + currentRect = Rect.fromLTRB( + rect.left.floorToDouble() - 4.0, + rect.top.floorToDouble() - 4.0, + rect.right.ceilToDouble() + 4.0, + rect.bottom.ceilToDouble() + 4.0, + ); + final SemanticsConfiguration configuration = SemanticsConfiguration() + ..sortKey = OrdinalSortKey(ordinal++) + ..textDirection = initialDirection + ..label = info.semanticsLabel ?? info.text; + final GestureRecognizer? recognizer = info.recognizer; + if (recognizer != null) { + if (recognizer is TapGestureRecognizer) { + if (recognizer.onTap != null) { + configuration.onTap = recognizer.onTap; + configuration.isLink = true; + } + } else if (recognizer is DoubleTapGestureRecognizer) { + if (recognizer.onDoubleTap != null) { + configuration.onTap = recognizer.onDoubleTap; + configuration.isLink = true; + } + } else if (recognizer is LongPressGestureRecognizer) { + if (recognizer.onLongPress != null) { + configuration.onLongPress = recognizer.onLongPress; + } + } else { + assert(false, '${recognizer.runtimeType} is not supported.'); + } + } + final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true) + ? _cachedChildNodes!.removeFirst() + : SemanticsNode(); + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache.addLast(newChild); + newChildren.add(newChild); } - final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true) - ? _cachedChildNodes!.removeFirst() - : SemanticsNode(); - newChild - ..updateWith(config: configuration) - ..rect = currentRect; - newChildCache.addLast(newChild); - newChildren.add(newChild); } _cachedChildNodes = newChildCache; node.updateWith(config: config, childrenInInversePaintOrder: newChildren); @@ -3052,6 +3098,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { redepthChild(foregroundChild); if (backgroundChild != null) redepthChild(backgroundChild); + super.redepthChildren(); } @override @@ -3062,6 +3109,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { visitor(foregroundChild); if (backgroundChild != null) visitor(backgroundChild); + super.visitChildren(visitor); } bool get _isMultiline => maxLines != 1; @@ -3268,14 +3316,49 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { @override @protected 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)); - return true; + hitText = true; } - return false; + + // Hit test render object children + RenderBox? child = firstChild; + int childIndex = 0; + while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { + final TextParentData textParentData = child.parentData! as TextParentData; + final Matrix4 transform = Matrix4.translationValues( + textParentData.offset.dx, + textParentData.offset.dy, + 0.0, + )..scale( + textParentData.scale, + textParentData.scale, + textParentData.scale, + ); + final bool isHit = result.addWithPaintTransform( + transform: transform, + position: position, + hitTest: (BoxHitTestResult result, Offset? transformed) { + assert(() { + final Offset manualPosition = (position - textParentData.offset) / textParentData.scale!; + return (transformed!.dx - manualPosition.dx).abs() < precisionErrorTolerance + && (transformed.dy - manualPosition.dy).abs() < precisionErrorTolerance; + }()); + return child!.hitTest(result, position: transformed!); + }, + ); + if (isHit) { + return true; + } + child = childAfter(child); + childIndex += 1; + } + return hitText; } late TapGestureRecognizer _tap; @@ -3532,6 +3615,82 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return TextSelection(baseOffset: line.start, extentOffset: line.end); } + // Placeholder dimensions representing the sizes of child inline widgets. + // + // These need to be cached because the text painter's placeholder dimensions + // will be overwritten during intrinsic width/height calculations and must be + // restored to the original values before final layout and painting. + List? _placeholderDimensions; + + // Layout the child inline widgets. We then pass the dimensions of the + // children to _textPainter so that appropriate placeholders can be inserted + // into the LibTxt layout. This does not do anything if no inline widgets were + // specified. + List _layoutChildren(BoxConstraints constraints, {bool dry = false}) { + if (childCount == 0) { + _textPainter.setPlaceholderDimensions([]); + return []; + } + RenderBox? child = firstChild; + final List placeholderDimensions = List.filled(childCount, PlaceholderDimensions.empty, growable: false); + int childIndex = 0; + // Only constrain the width to the maximum width of the paragraph. + // Leave height unconstrained, which will overflow if expanded past. + BoxConstraints boxConstraints = BoxConstraints(maxWidth: constraints.maxWidth); + // The content will be enlarged by textScaleFactor during painting phase. + // We reduce constraints by textScaleFactor, so that the content will fit + // into the box once it is enlarged. + boxConstraints = boxConstraints / textScaleFactor; + while (child != null) { + double? baselineOffset; + final Size childSize; + if (!dry) { + child.layout( + boxConstraints, + parentUsesSize: true, + ); + childSize = child.size; + switch (_placeholderSpans[childIndex].alignment) { + case ui.PlaceholderAlignment.baseline: + baselineOffset = child.getDistanceToBaseline( + _placeholderSpans[childIndex].baseline!, + ); + break; + default: + baselineOffset = null; + break; + } + } else { + assert(_placeholderSpans[childIndex].alignment != ui.PlaceholderAlignment.baseline); + childSize = child.getDryLayout(boxConstraints); + } + placeholderDimensions[childIndex] = PlaceholderDimensions( + size: childSize, + alignment: _placeholderSpans[childIndex].alignment, + baseline: _placeholderSpans[childIndex].baseline, + baselineOffset: baselineOffset, + ); + child = childAfter(child); + childIndex += 1; + } + return placeholderDimensions; + } + + void _setParentData() { + RenderBox? child = firstChild; + int childIndex = 0; + while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { + final TextParentData textParentData = child.parentData! as TextParentData; + textParentData.offset = Offset( + _textPainter.inlinePlaceholderBoxes![childIndex].left, + _textPainter.inlinePlaceholderBoxes![childIndex].top, + ); + textParentData.scale = _textPainter.inlinePlaceholderScales![childIndex]; + child = childAfter(child); + childIndex += 1; + } + } + void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) { assert(maxWidth != null && minWidth != null); if (_textLayoutLastMaxWidth == maxWidth && _textLayoutLastMinWidth == minWidth) @@ -3592,8 +3751,34 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ); } + bool _canComputeDryLayout() { + // Dry layout cannot be calculated without a full layout for + // alignments that require the baseline (baseline, aboveBaseline, + // belowBaseline). + for (final PlaceholderSpan span in _placeholderSpans) { + switch (span.alignment) { + case ui.PlaceholderAlignment.baseline: + case ui.PlaceholderAlignment.aboveBaseline: + case ui.PlaceholderAlignment.belowBaseline: + return false; + case ui.PlaceholderAlignment.top: + case ui.PlaceholderAlignment.middle: + case ui.PlaceholderAlignment.bottom: + continue; + } + } + return true; + } + @override Size computeDryLayout(BoxConstraints constraints) { + if (!_canComputeDryLayout()) { + assert(debugCannotComputeDryLayout( + reason: 'Dry layout not available for alignments that require baseline.', + )); + return Size.zero; + } + _textPainter.setPlaceholderDimensions(_layoutChildren(constraints, dry: true)); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); final double width = forceLine ? constraints.maxWidth : constraints .constrainWidth(_textPainter.size.width + _caretMargin); @@ -3603,7 +3788,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { @override void performLayout() { final BoxConstraints constraints = this.constraints; + _placeholderDimensions = _layoutChildren(constraints); + _textPainter.setPlaceholderDimensions(_placeholderDimensions); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + _setParentData(); _computeCaretPrototype(); // We grab _textPainter.size here because assigning to `size` on the next // line will trigger us to validate our intrinsic sizes, which will change @@ -3739,6 +3927,31 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _textPainter.paint(context.canvas, effectiveOffset); + RenderBox? child = firstChild; + int childIndex = 0; + // childIndex might be out of index of placeholder boxes. This can happen + // if engine truncates children due to ellipsis. Sadly, we would not know + // it until we finish layout, and RenderObject is in immutable state at + // this point. + while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { + final TextParentData textParentData = child.parentData! as TextParentData; + + final double scale = textParentData.scale!; + context.pushTransform( + needsCompositing, + effectiveOffset + textParentData.offset, + Matrix4.diagonal3Values(scale, scale, scale), + (PaintingContext context, Offset offset) { + context.paintChild( + child!, + offset, + ); + }, + ); + child = childAfter(child); + childIndex += 1; + } + if (foregroundChild != null) context.paintChild(foregroundChild, offset); } diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 701578167f..ef5ee3dd2b 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -18,7 +18,7 @@ import 'object.dart'; const String _kEllipsis = '\u2026'; -/// Parent data for use with [RenderParagraph]. +/// Parent data for use with [RenderParagraph] and [RenderEditable]. class TextParentData extends ContainerBoxParentData { /// The scaling of the text. double? scale; @@ -434,14 +434,12 @@ class RenderParagraph extends RenderBox @override bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { // Hit test text spans. - late final bool hitText; + bool hitText = false; final TextPosition textPosition = _textPainter.getPositionForOffset(position); final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition); if (span != null && span is HitTestTarget) { result.add(HitTestEntry(span as HitTestTarget)); hitText = true; - } else { - hitText = false; } // Hit test render object children @@ -545,16 +543,14 @@ class RenderParagraph extends RenderBox ); childSize = child.size; switch (_placeholderSpans[childIndex].alignment) { - case ui.PlaceholderAlignment.baseline: { + case ui.PlaceholderAlignment.baseline: baselineOffset = child.getDistanceToBaseline( _placeholderSpans[childIndex].baseline!, ); break; - } - default: { + default: baselineOffset = null; break; - } } } else { assert(_placeholderSpans[childIndex].alignment != ui.PlaceholderAlignment.baseline); @@ -597,14 +593,12 @@ class RenderParagraph extends RenderBox switch (span.alignment) { case ui.PlaceholderAlignment.baseline: case ui.PlaceholderAlignment.aboveBaseline: - case ui.PlaceholderAlignment.belowBaseline: { + case ui.PlaceholderAlignment.belowBaseline: return false; - } case ui.PlaceholderAlignment.top: case ui.PlaceholderAlignment.middle: - case ui.PlaceholderAlignment.bottom: { + case ui.PlaceholderAlignment.bottom: continue; - } } } return true; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 95d02deb0d..5283c0c2fb 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -31,6 +31,7 @@ import 'text.dart'; import 'text_editing_action.dart'; import 'text_selection.dart'; import 'ticker_provider.dart'; +import 'widget_span.dart'; export 'package:flutter/services.dart' show SelectionChangedCause, TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; @@ -197,9 +198,8 @@ class TextEditingController extends ValueNotifier { if (!value.isComposingRangeValid || !withComposing) { return TextSpan(style: style, text: text); } - final TextStyle composingStyle = style!.merge( - const TextStyle(decoration: TextDecoration.underline), - ); + final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline)) + ?? const TextStyle(decoration: TextDecoration.underline); return TextSpan( style: style, children: [ @@ -2651,7 +2651,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien key: _editableKey, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, - textSpan: buildTextSpan(), + inlineSpan: buildTextSpan(), value: _value, cursorColor: _cursorColor, backgroundCursorColor: widget.backgroundCursorColor, @@ -2730,10 +2730,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } -class _Editable extends LeafRenderObjectWidget { - const _Editable({ +class _Editable extends MultiChildRenderObjectWidget { + _Editable({ Key? key, - required this.textSpan, + required this.inlineSpan, required this.value, required this.startHandleLayerLink, required this.endHandleLayerLink, @@ -2778,9 +2778,22 @@ class _Editable extends LeafRenderObjectWidget { required this.clipBehavior, }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), - super(key: key); + super(key: key, children: _extractChildren(inlineSpan)); - final TextSpan textSpan; + // Traverses the InlineSpan tree and depth-first collects the list of + // child widgets that are created in WidgetSpans. + static List _extractChildren(InlineSpan span) { + final List result = []; + span.visitChildren((InlineSpan span) { + if (span is WidgetSpan) { + result.add(span.child); + } + return true; + }); + return result; + } + + final InlineSpan inlineSpan; final TextEditingValue value; final Color? cursorColor; final LayerLink startHandleLayerLink; @@ -2827,7 +2840,7 @@ class _Editable extends LeafRenderObjectWidget { @override RenderEditable createRenderObject(BuildContext context) { return RenderEditable( - text: textSpan, + text: inlineSpan, cursorColor: cursorColor, startHandleLayerLink: startHandleLayerLink, endHandleLayerLink: endHandleLayerLink, @@ -2872,7 +2885,7 @@ class _Editable extends LeafRenderObjectWidget { @override void updateRenderObject(BuildContext context, RenderEditable renderObject) { renderObject - ..text = textSpan + ..text = inlineSpan ..cursorColor = cursorColor ..startHandleLayerLink = startHandleLayerLink ..endHandleLayerLink = endHandleLayerLink diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 5348953a63..7efe682751 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1056,12 +1056,12 @@ void main() { await tester.pump(); - String editText = findRenderEditable(tester).text!.text!; + String editText = (findRenderEditable(tester).text! as TextSpan).text!; expect(editText.substring(editText.length - 1), newChar); await tester.pump(const Duration(seconds: 2)); - editText = findRenderEditable(tester).text!.text!; + editText = (findRenderEditable(tester).text! as TextSpan).text!; expect(editText.substring(editText.length - 1), '\u2022'); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android })); @@ -1096,7 +1096,7 @@ void main() { await tester.pump(); - final String editText = findRenderEditable(tester).text!.text!; + final String editText = (findRenderEditable(tester).text! as TextSpan).text!; expect(editText.substring(editText.length - 1), '\u2022'); }, variant: const TargetPlatformVariant({ TargetPlatform.macOS, diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index a9f3d7e378..9a6771193e 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -3212,11 +3212,11 @@ void main() { pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 1); - editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = false; + editable.foregroundPainter = (currentPainter = _TestRenderEditablePainter()..repaint = false); pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 0); - editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = true; + editable.foregroundPainter = (currentPainter = _TestRenderEditablePainter()..repaint = true); pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 1); }); @@ -3231,11 +3231,11 @@ void main() { pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 1); - editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = false; + editable.painter = (currentPainter = _TestRenderEditablePainter()..repaint = false); pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 0); - editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = true; + editable.painter = (currentPainter = _TestRenderEditablePainter()..repaint = true); pumpFrame(phase: EnginePhase.paint); expect(currentPainter.paintCount, 1); }); @@ -3547,6 +3547,387 @@ void main() { expect(textEditingValue.composing, const TextRange(start: 2, end: 5)); }); }); + + group('WidgetSpan support', () { + test('able to render basic WidgetSpan', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: TextSpan( + style: const TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + const TextSpan(text: 'test'), + WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + children: renderBoxes, + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + final Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 5))!; + expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('able to render multiple WidgetSpans', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: TextSpan( + style: const TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + const TextSpan(text: 'test'), + WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)), + WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)), + WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + children: renderBoxes, + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + final Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 7))!; + expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 82.0, 14.0)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('able to render WidgetSpans with line wrap', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + TextSpan(text: 'test'), + WidgetSpan(child: Text('b')), + WidgetSpan(child: Text('c')), + WidgetSpan(child: Text('d')), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + maxLines: 2, + minLines: 2, + children: renderBoxes, + ); + + // Force a line wrap + layout(editable, constraints: const BoxConstraints(maxWidth: 75)); + editable.hasFocus = true; + pumpFrame(); + + Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!; + expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!; + expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('able to render WidgetSpans with line wrap alternating spans', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'e'), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + TextSpan(text: 'test'), + WidgetSpan(child: Text('b')), + WidgetSpan(child: Text('c')), + WidgetSpan(child: Text('d')), + TextSpan(text: 'HI'), + WidgetSpan(child: Text('e')), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + maxLines: 2, + minLines: 2, + children: renderBoxes, + ); + + // Force a line wrap + layout(editable, constraints: const BoxConstraints(maxWidth: 75)); + editable.hasFocus = true; + pumpFrame(); + + Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!; + expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!; + expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8))!; // H + expect(composingRect, const Rect.fromLTRB(14.0, 18.0, 24.0, 28.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 8, end: 9))!; // I + expect(composingRect, const Rect.fromLTRB(24.0, 18.0, 34.0, 28.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 9, end: 10))!; + expect(composingRect, const Rect.fromLTRB(34.0, 14.0, 48.0, 28.0)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('able to render WidgetSpans nested spans', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + TextSpan(text: 'test'), + WidgetSpan(child: Text('a')), + TextSpan(children: [ + WidgetSpan(child: Text('b')), + WidgetSpan(child: Text('c')), + ], + ), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + maxLines: 2, + minLines: 2, + children: renderBoxes, + ); + + // Force a line wrap + layout(editable, constraints: const BoxConstraints(maxWidth: 75)); + editable.hasFocus = true; + pumpFrame(); + + Rect? composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 5)); + expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 5, end: 6)); + expect(composingRect, const Rect.fromLTRB(54.0, 0.0, 68.0, 14.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7)); + expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)); + composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8)); + expect(composingRect, null); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('can compute IntrinsicWidth for WidgetSpans', () { + // Regression test for https://github.com/flutter/flutter/issues/59316 + const double screenWidth = 1000.0; + const double fixedHeight = 1000.0; + const String sentence = 'one two'; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr), + ]; + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + children: [ + TextSpan(text: 'test'), + WidgetSpan(child: Text('a')), + ], + ), + selection: const TextSelection.collapsed(offset: 3), + maxLines: 2, + minLines: 2, + textScaleFactor: 2.0, + children: renderBoxes, + ); + layout(editable, constraints: const BoxConstraints(maxWidth: screenWidth)); + editable.hasFocus = true; + final double maxIntrinsicWidth = editable.computeMaxIntrinsicWidth(fixedHeight); + pumpFrame(); + + expect(maxIntrinsicWidth, 278); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 + + test('hits correct WidgetSpan when not scrolled', () { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 3), + ); + final List renderBoxes = [ + RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr), + RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr), + ]; + final RenderEditable editable = RenderEditable( + text: const TextSpan( + style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Ahem'), + children: [ + TextSpan(text: 'test'), + WidgetSpan(child: Text('a')), + TextSpan(children: [ + WidgetSpan(child: Text('b')), + WidgetSpan(child: Text('c')), + ], + ), + ], + ), + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + textDirection: TextDirection.ltr, + offset: ViewportOffset.fixed(0.0), + textSelectionDelegate: delegate, + selection: const TextSelection.collapsed( + offset: 0, + ), + children: renderBoxes, + ); + layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0))); + // Prepare for painting after layout. + pumpFrame(phase: EnginePhase.compositingBits); + BoxHitTestResult result = BoxHitTestResult(); + editable.hitTest(result, position: Offset.zero); + // We expect two hit test entries in the path because the RenderEditable + // will add itself as well. + expect(result.path, hasLength(2)); + HitTestTarget target = result.path.first.target; + expect(target, isA()); + expect((target as TextSpan).text, 'test'); + // Only testing the RenderEditable entry here once, not anymore below. + expect(result.path.last.target, isA()); + result = BoxHitTestResult(); + editable.hitTest(result, position: const Offset(15.0, 0.0)); + expect(result.path, hasLength(2)); + target = result.path.first.target; + expect(target, isA()); + expect((target as TextSpan).text, 'test'); + + result = BoxHitTestResult(); + editable.hitTest(result, position: const Offset(41.0, 0.0)); + expect(result.path, hasLength(3)); + target = result.path.first.target; + expect(target, isA()); + expect((target as TextSpan).text, 'a'); + + result = BoxHitTestResult(); + editable.hitTest(result, position: const Offset(55.0, 0.0)); + expect(result.path, hasLength(3)); + target = result.path.first.target; + expect(target, isA()); + expect((target as TextSpan).text, 'b'); + + result = BoxHitTestResult(); + editable.hitTest(result, position: const Offset(69.0, 5.0)); + expect(result.path, hasLength(3)); + target = result.path.first.target; + expect(target, isA()); + expect((target as TextSpan).text, 'c'); + + result = BoxHitTestResult(); + editable.hitTest(result, position: const Offset(5.0, 15.0)); + expect(result.path, hasLength(0)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 + }); } class _TestRenderEditable extends RenderEditable { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 5e296d7fe4..eed625c9bb 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2841,7 +2841,7 @@ void main() { ), )); - expect(findRenderEditable(tester).text!.text, expectedValue); + expect((findRenderEditable(tester).text! as TextSpan).text, expectedValue); expect( semantics, @@ -2906,7 +2906,7 @@ void main() { )); final String expectedValue = obscuringCharacter * originalText.length; - expect(findRenderEditable(tester).text!.text, expectedValue); + expect((findRenderEditable(tester).text! as TextSpan).text, expectedValue); }); group('a11y copy/cut/paste', () { @@ -3808,17 +3808,17 @@ void main() { final RenderEditable renderEditable = findRenderEditable(tester); // The actual text span is split into 3 parts with the middle part underlined. - expect(renderEditable.text!.children!.length, 3); - final TextSpan textSpan = renderEditable.text!.children![1] as TextSpan; + expect((renderEditable.text! as TextSpan).children!.length, 3); + final TextSpan textSpan = (renderEditable.text! as TextSpan).children![1] as TextSpan; expect(textSpan.text, 'composing'); expect(textSpan.style!.decoration, TextDecoration.underline); focusNode.unfocus(); await tester.pump(); - expect(renderEditable.text!.children, isNull); - // Everything's just formatted the same way now. - expect(renderEditable.text!.text, 'text composing text'); + expect((renderEditable.text! as TextSpan).children, isNull); + // Everything's just formated the same way now. + expect((renderEditable.text! as TextSpan).text, 'text composing text'); expect(renderEditable.text!.style!.decoration, isNull); }); @@ -6137,7 +6137,7 @@ void main() { )); final RenderEditable renderEditable = findRenderEditable(tester); - final TextSpan textSpan = renderEditable.text!; + final TextSpan textSpan = renderEditable.text! as TextSpan; expect(textSpan.style!.color, color); }); });