From b5df180a6f0d4754b7e82f36cae89033d46ddbbf Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 26 May 2023 16:47:33 -0700 Subject: [PATCH] Move shared inline widget logic to `RenderInlineWidgetContainerDefaults` (#127308) - Added `InlineWidgetContainerDefaults` for deduping inline widget code - Added a helper function `WidgetSpan.extractFromInlineSpan` for extracting `WidgetSpan`s and automatically applying text scaling (at widget level) - Removed `TextPainter.inlinePlaceholderScales`. I'm going to deprecate the `scale` argument in `TextPainter.addPlaceholder` next, as scaling is now done at the widget level. - Added runtime check and comments to make sure nobody is extending `PlaceholderSpan` directly (unfortunately we can't remove `PlaceholderSpan` without moving RenderEditable and RenderParagraph to the widgets library). --- .../flutter/lib/src/painting/inline_span.dart | 5 +- .../lib/src/painting/placeholder_span.dart | 20 +- .../lib/src/painting/text_painter.dart | 20 +- packages/flutter/lib/src/rendering/box.dart | 3 +- .../flutter/lib/src/rendering/editable.dart | 213 +------ .../flutter/lib/src/rendering/paragraph.dart | 547 +++++++++--------- .../flutter/lib/src/rendering/proxy_box.dart | 46 +- packages/flutter/lib/src/widgets/basic.dart | 19 +- .../lib/src/widgets/editable_text.dart | 15 +- .../flutter/lib/src/widgets/widget_span.dart | 208 ++++++- .../flutter/test/rendering/editable_test.dart | 37 +- .../test/rendering/paragraph_test.dart | 110 +--- packages/flutter/test/widgets/text_test.dart | 55 +- 13 files changed, 648 insertions(+), 650 deletions(-) diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart index 8398549f71..9037b99c09 100644 --- a/packages/flutter/lib/src/painting/inline_span.dart +++ b/packages/flutter/lib/src/painting/inline_span.dart @@ -395,9 +395,6 @@ abstract class InlineSpan extends DiagnosticableTree { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.whitespace; - - if (style != null) { - style!.debugFillProperties(properties); - } + style?.debugFillProperties(properties); } } diff --git a/packages/flutter/lib/src/painting/placeholder_span.dart b/packages/flutter/lib/src/painting/placeholder_span.dart index 35c912ab69..628a374102 100644 --- a/packages/flutter/lib/src/painting/placeholder_span.dart +++ b/packages/flutter/lib/src/painting/placeholder_span.dart @@ -15,12 +15,16 @@ import 'text_style.dart'; /// An immutable placeholder that is embedded inline within text. /// /// [PlaceholderSpan] represents a placeholder that acts as a stand-in for other -/// content. A [PlaceholderSpan] by itself does not contain useful -/// information to change a [TextSpan]. Instead, this class must be extended -/// to define contents. +/// content. A [PlaceholderSpan] by itself does not contain useful information +/// to change a [TextSpan]. [WidgetSpan] from the widgets library extends +/// [PlaceholderSpan] and may be used instead to specify a widget as the contents +/// of the placeholder. +/// +/// Flutter widgets such as [TextField], [Text] and [RichText] do not recognize +/// [PlaceholderSpan] subclasses other than [WidgetSpan]. **Consider +/// implementing the [WidgetSpan] interface instead of the [Placeholder] +/// interface.** /// -/// [WidgetSpan] from the widgets library extends [PlaceholderSpan] and may be -/// used instead to specify a widget as the contents of the placeholder. /// /// See also: /// @@ -89,4 +93,10 @@ abstract class PlaceholderSpan extends InlineSpan { properties.add(EnumProperty('alignment', alignment, defaultValue: null)); properties.add(EnumProperty('baseline', baseline, defaultValue: null)); } + + @override + bool debugAssertIsValid() { + assert(false, 'Consider implementing the WidgetSpan interface instead.'); + return super.debugAssertIsValid(); + } } diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 3f66b38d99..8f4669e4f7 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -124,7 +124,14 @@ class PlaceholderDimensions { @override String toString() { - return 'PlaceholderDimensions($size, $baseline${baselineOffset == null ? ", $baselineOffset" : ""})'; + return switch (alignment) { + ui.PlaceholderAlignment.top || + ui.PlaceholderAlignment.bottom || + ui.PlaceholderAlignment.middle || + ui.PlaceholderAlignment.aboveBaseline || + ui.PlaceholderAlignment.belowBaseline => 'PlaceholderDimensions($size, $alignment)', + ui.PlaceholderAlignment.baseline => 'PlaceholderDimensions($size, $alignment($baselineOffset from top))', + }; } } @@ -863,16 +870,6 @@ class TextPainter { return rawBoxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false); } - /// An ordered list of scales for each placeholder in the paragraph. - /// - /// The scale is used as a multiplier on the height, width and baselineOffset of - /// the placeholder. Scale is primarily used to handle accessibility scaling. - /// - /// Each scale corresponds to a [PlaceholderSpan] in the order they were defined - /// in the [InlineSpan] tree. - List? get inlinePlaceholderScales => _inlinePlaceholderScales; - List? _inlinePlaceholderScales; - /// Sets the dimensions of each placeholder in [text]. /// /// The number of [PlaceholderDimensions] provided should be the same as the @@ -1029,7 +1026,6 @@ class TextPainter { ui.Paragraph _createParagraph(InlineSpan text) { final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle()); text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions); - _inlinePlaceholderScales = builder.placeholderScales; assert(() { _debugMarkNeedsLayoutCallStack = null; return true; diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index 0832908d6e..cbe02bd046 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -2170,8 +2170,7 @@ abstract class RenderBox extends RenderObject { double? getDistanceToActualBaseline(TextBaseline baseline) { assert(_debugDoingBaseline, 'Please see the documentation for computeDistanceToActualBaseline for the required calling conventions of this method.'); _cachedBaselines ??= {}; - _cachedBaselines!.putIfAbsent(baseline, () => computeDistanceToActualBaseline(baseline)); - return _cachedBaselines![baseline]; + return _cachedBaselines!.putIfAbsent(baseline, () => computeDistanceToActualBaseline(baseline)); } /// Returns the distance from the y-coordinate of the position of the box to diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index ffdad4cff6..cf76e38f0b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -15,6 +15,7 @@ import 'package:flutter/services.dart'; import 'box.dart'; import 'custom_paint.dart'; import 'layer.dart'; +import 'layout_helper.dart'; import 'object.dart'; import 'paragraph.dart'; import 'viewport_offset.dart'; @@ -265,7 +266,7 @@ class VerticalCaretMovementRun implements Iterator { /// 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, ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin implements TextLayoutMetrics { +class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ContainerRenderObjectMixin, RenderInlineChildrenContainerDefaults implements TextLayoutMetrics { /// 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]. @@ -385,14 +386,6 @@ 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 @@ -435,17 +428,6 @@ 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. /// @@ -826,7 +808,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, _textPainter.text = value; _cachedAttributedValue = null; _cachedCombinedSemanticsInfos = null; - _extractPlaceholderSpans(value); + _canComputeIntrinsicsCached = null; markNeedsTextLayout(); markNeedsSemanticsUpdate(); } @@ -1412,13 +1394,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { final SemanticsNode childNode = children.elementAt(childIndex); final TextParentData parentData = child!.parentData! as TextParentData; - assert(parentData.scale != null); - childNode.rect = Rect.fromLTWH( - childNode.rect.left, - childNode.rect.top, - childNode.rect.width * parentData.scale!, - childNode.rect.height * parentData.scale!, - ); + assert(parentData.offset != null); newChildren.add(childNode); childIndex += 1; } @@ -1931,9 +1907,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, @override @protected bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - // Hit test text spans. - bool hitText = false; - final InlineSpan? textSpan = _textPainter.text; if (textSpan != null) { final Offset effectivePosition = position - _paintOffset; @@ -1941,42 +1914,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, 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; - 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; + return hitTestInlineChildren(result, position); } late TapGestureRecognizer _tap; @@ -2235,77 +2176,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, // 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); - 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!, - ); - case ui.PlaceholderAlignment.aboveBaseline: - case ui.PlaceholderAlignment.belowBaseline: - case ui.PlaceholderAlignment.bottom: - case ui.PlaceholderAlignment.middle: - case ui.PlaceholderAlignment.top: - baselineOffset = null; - } - } 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 }) { final double availableMaxWidth = math.max(0.0, maxWidth - _caretMargin); final double availableMinWidth = math.min(minWidth, availableMaxWidth); @@ -2377,34 +2247,31 @@ 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; + bool _canComputeDryLayoutForInlineWidgets() { + return text?.visitChildren((InlineSpan span) { + return (span is! PlaceholderSpan) || switch (span.alignment) { + ui.PlaceholderAlignment.baseline || + ui.PlaceholderAlignment.aboveBaseline || + ui.PlaceholderAlignment.belowBaseline => false, + ui.PlaceholderAlignment.top || + ui.PlaceholderAlignment.middle || + ui.PlaceholderAlignment.bottom => true, + }; + }) ?? true; } + bool? _canComputeIntrinsicsCached; + bool get _canComputeIntrinsics => _canComputeIntrinsicsCached ??= _canComputeDryLayoutForInlineWidgets(); + @override Size computeDryLayout(BoxConstraints constraints) { - if (!_canComputeDryLayout()) { + if (!_canComputeIntrinsics) { assert(debugCannotComputeDryLayout( reason: 'Dry layout not available for alignments that require baseline.', )); return Size.zero; } - _textPainter.setPlaceholderDimensions(_layoutChildren(constraints, dry: true)); + _textPainter.setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild)); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); final double width = forceLine ? constraints.maxWidth : constraints .constrainWidth(_textPainter.size.width + _caretMargin); @@ -2414,10 +2281,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, @override void performLayout() { final BoxConstraints constraints = this.constraints; - _placeholderDimensions = _layoutChildren(constraints); + _placeholderDimensions = layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.layoutChild); _textPainter.setPlaceholderDimensions(_placeholderDimensions); _computeTextMetricsIfNeeded(); - _setParentData(); + positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); _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 @@ -2592,31 +2459,7 @@ 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; - } + paintInlineChildren(context, offset); if (foregroundChild != null) { context.paintChild(foregroundChild, offset); @@ -2648,6 +2491,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, } } + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + if (child == _foregroundRenderObject || child == _backgroundRenderObject) { + return; + } + defaultApplyPaintTransform(child, transform); + } + @override void paint(PaintingContext context, Offset offset) { _computeTextMetricsIfNeeded(); diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index e07283abea..f36c6fee6f 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -13,29 +13,13 @@ import 'package:flutter/services.dart'; import 'box.dart'; import 'debug.dart'; -import 'editable.dart'; import 'layer.dart'; +import 'layout_helper.dart'; import 'object.dart'; import 'selection.dart'; const String _kEllipsis = '\u2026'; -/// Parent data for use with [RenderParagraph] and [RenderEditable]. -class TextParentData extends ContainerBoxParentData { - /// The scaling of the text. - double? scale; - - @override - String toString() { - final List values = [ - 'offset=$offset', - if (scale != null) 'scale=$scale', - super.toString(), - ]; - return values.join('; '); - } -} - /// Used by the [RenderParagraph] to map its rendering children to their /// corresponding semantics nodes. /// @@ -62,11 +46,210 @@ class PlaceholderSpanIndexSemanticsTag extends SemanticsTag { int get hashCode => Object.hash(PlaceholderSpanIndexSemanticsTag, index); } +/// Parent data used by [RenderParagraph] and [RenderEditable] to annotate +/// inline contents (such as [WidgetSpan]s) with. +class TextParentData extends ParentData with ContainerParentDataMixin { + /// The offset at which to paint the child in the parent's coordinate system. + /// + /// A `null` value indicates this inline widget is not laid out. For instance, + /// when the inline widget has never been laid out, or the inline widget is + /// ellipsized away. + Offset? get offset => _offset; + Offset? _offset; + + /// The [PlaceholderSpan] associated with this render child. + /// + /// This field is usually set by a [ParentDataWidget], and is typically not + /// null when `performLayout` is called. + PlaceholderSpan? span; + + @override + void detach() { + span = null; + _offset = null; + super.detach(); + } + + @override + String toString() =>'widget: $span, ${offset == null ? "not laid out" : "offset: $offset"}'; +} + +/// A mixin that provides useful default behaviors for text [RenderBox]es +/// ([RenderParagraph] and [RenderEditable] for example) with inline content +/// children managed by the [ContainerRenderObjectMixin] mixin. +/// +/// This mixin assumes every child managed by the [ContainerRenderObjectMixin] +/// mixin corresponds to a [PlaceholderSpan], and they are organized in logical +/// order of the text (the order each [PlaceholderSpan] is encountered when the +/// user reads the text). +/// +/// To use this mixin in a [RenderBox] class: +/// +/// * Call [layoutInlineChildren] in the `performLayout` and `computeDryLayout` +/// implementation, and during intrinsic size calculations, to get the size +/// information of the inline widgets as a `List` of `PlaceholderDimensions`. +/// Determine the positioning of the inline widgets (which is usually done by +/// a [TextPainter] using its line break algorithm). +/// +/// * Call [positionInlineChildren] with the positioning information of the +/// inline widgets. +/// +/// * Implement [RenderBox.applyPaintTransform], optionally with +/// [defaultApplyPaintTransform]. +/// +/// * Call [paintInlineChildren] in [RenderBox.paint] to paint the inline widgets. +/// +/// * Call [hitTestInlineChildren] in [RenderBox.hitTestChildren] to hit test the +/// inline widgets. +/// +/// See also: +/// +/// * [WidgetSpan.extractFromInlineSpan], a helper function for extracting +/// [WidgetSpan]s from an [InlineSpan] tree. +mixin RenderInlineChildrenContainerDefaults on RenderBox, ContainerRenderObjectMixin { + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TextParentData) { + child.parentData = TextParentData(); + } + } + + static PlaceholderDimensions _layoutChild(RenderBox child, double maxWidth, ChildLayouter layoutChild) { + final TextParentData parentData = child.parentData! as TextParentData; + final PlaceholderSpan? span = parentData.span; + assert(span != null); + return span == null + ? PlaceholderDimensions.empty + : PlaceholderDimensions( + size: layoutChild(child, BoxConstraints(maxWidth: maxWidth)), + alignment: span.alignment, + baseline: span.baseline, + baselineOffset: switch (span.alignment) { + ui.PlaceholderAlignment.aboveBaseline || + ui.PlaceholderAlignment.belowBaseline || + ui.PlaceholderAlignment.bottom || + ui.PlaceholderAlignment.middle || + ui.PlaceholderAlignment.top => null, + ui.PlaceholderAlignment.baseline => child.getDistanceToBaseline(span.baseline!), + }, + ); + } + + /// Computes the layout for every inline child using the given `layoutChild` + /// function and the `maxWidth` constraint. + /// + /// Returns a list of [PlaceholderDimensions], representing the layout results + /// for each child managed by the [ContainerRenderObjectMixin] mixin. + /// + /// Since this method does not impose a maximum height constraint on the + /// inline children, some children may become taller than this [RenderBox]. + /// + /// See also: + /// + /// * [TextPainter.setPlaceholderDimensions], the method that usually takes + /// the layout results from this method as the input. + @protected + List layoutInlineChildren(double maxWidth, ChildLayouter layoutChild) { + return [ + for (RenderBox? child = firstChild; child != null; child = childAfter(child)) + _layoutChild(child, maxWidth, layoutChild), + ]; + } + + /// Positions each inline child according to the coordinates provided in the + /// `boxes` list. + /// + /// The `boxes` list must be in logical order, which is the order each child + /// is encountered when the user reads the text. Usually the length of the + /// list equals [childCount], but it can be less than that, when some children + /// are ommitted due to ellipsing. It never exceeds [childCount]. + /// + /// See also: + /// + /// * [TextPainter.inlinePlaceholderBoxes], the method that can be used to + /// get the input `boxes`. + @protected + void positionInlineChildren(List boxes) { + RenderBox? child = firstChild; + for (final ui.TextBox box in boxes) { + if (child == null) { + assert(false, 'The length of boxes (${boxes.length}) should be greater than childCount ($childCount)'); + return; + } + final TextParentData textParentData = child.parentData! as TextParentData; + textParentData._offset = Offset(box.left, box.top); + child = childAfter(child); + } + while (child != null) { + final TextParentData textParentData = child.parentData! as TextParentData; + textParentData._offset = null; + child = childAfter(child); + } + } + + /// Applies the transform that would be applied when painting the given child + /// to the given matrix. + /// + /// Render children whose [TextParentData.offset] is null zeros out the + /// `transform` to indicate they're invisible thus should not be painted. + @protected + void defaultApplyPaintTransform(RenderBox child, Matrix4 transform) { + final TextParentData childParentData = child.parentData! as TextParentData; + final Offset? offset = childParentData.offset; + if (offset == null) { + transform.setZero(); + } else { + transform.translate(offset.dx, offset.dy); + } + } + + /// Paints each inline child. + /// + /// Render children whose [TextParentData.offset] is null will be skipped by + /// this method. + @protected + void paintInlineChildren(PaintingContext context, Offset offset) { + RenderBox? child = firstChild; + while (child != null) { + final TextParentData childParentData = child.parentData! as TextParentData; + final Offset? childOffset = childParentData.offset; + if (childOffset == null) { + return; + } + context.paintChild(child, childOffset + offset); + child = childAfter(child); + } + } + + /// Performs a hit test on each inline child. + /// + /// Render children whose [TextParentData.offset] is null will be skipped by + /// this method. + @protected + bool hitTestInlineChildren(BoxHitTestResult result, Offset position) { + RenderBox? child = firstChild; + while (child != null) { + final TextParentData childParentData = child.parentData! as TextParentData; + final Offset? childOffset = childParentData.offset; + if (childOffset == null) { + return false; + } + final bool isHit = result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) => child!.hitTest(result, position: transformed), + ); + if (isHit) { + return true; + } + child = childAfter(child); + } + return false; + } +} + /// A render object that displays a paragraph of text. -class RenderParagraph extends RenderBox - with ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin, - RelayoutWhenSystemFontsChangeMixin { +class RenderParagraph extends RenderBox with ContainerRenderObjectMixin, RenderInlineChildrenContainerDefaults, RelayoutWhenSystemFontsChangeMixin { /// Creates a paragraph render object. /// /// The [text], [textAlign], [textDirection], [overflow], [softWrap], and @@ -106,17 +289,9 @@ class RenderParagraph extends RenderBox textHeightBehavior: textHeightBehavior, ) { addAll(children); - _extractPlaceholderSpans(text); this.registrar = registrar; } - @override - void setupParentData(RenderBox child) { - if (child.parentData is! TextParentData) { - child.parentData = TextParentData(); - } - } - static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); final TextPainter _textPainter; @@ -137,8 +312,8 @@ class RenderParagraph extends RenderBox case RenderComparison.paint: _textPainter.text = value; _cachedAttributedLabels = null; + _canComputeIntrinsicsCached = null; _cachedCombinedSemanticsInfos = null; - _extractPlaceholderSpans(value); markNeedsPaint(); markNeedsSemanticsUpdate(); case RenderComparison.layout: @@ -146,7 +321,7 @@ class RenderParagraph extends RenderBox _overflowShader = null; _cachedAttributedLabels = null; _cachedCombinedSemanticsInfos = null; - _extractPlaceholderSpans(value); + _canComputeIntrinsicsCached = null; markNeedsLayout(); _removeSelectionRegistrarSubscription(); _disposeSelectableFragments(); @@ -256,17 +431,6 @@ class RenderParagraph extends RenderBox super.dispose(); } - late List _placeholderSpans; - void _extractPlaceholderSpans(InlineSpan span) { - _placeholderSpans = []; - span.visitChildren((InlineSpan span) { - if (span is PlaceholderSpan) { - _placeholderSpans.add(span); - } - return true; - }); - } - /// How the text should be aligned horizontally. TextAlign get textAlign => _textPainter.textAlign; set textAlign(TextAlign value) { @@ -438,7 +602,10 @@ class RenderParagraph extends RenderBox if (!_canComputeIntrinsics()) { return 0.0; } - _computeChildrenWidthWithMinIntrinsics(height); + _textPainter.setPlaceholderDimensions(layoutInlineChildren( + double.infinity, + (RenderBox child, BoxConstraints constraints) => Size(child.getMinIntrinsicWidth(double.infinity), 0.0), + )); _layoutText(); // layout with infinite width. return _textPainter.minIntrinsicWidth; } @@ -448,7 +615,12 @@ class RenderParagraph extends RenderBox if (!_canComputeIntrinsics()) { return 0.0; } - _computeChildrenWidthWithMaxIntrinsics(height); + _textPainter.setPlaceholderDimensions(layoutInlineChildren( + double.infinity, + // Height and baseline is irrelevant as all text will be laid + // out in a single line. Therefore, using 0.0 as a dummy for the height. + (RenderBox child, BoxConstraints constraints) => Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), + )); _layoutText(); // layout with infinite width. return _textPainter.maxIntrinsicWidth; } @@ -457,7 +629,7 @@ class RenderParagraph extends RenderBox if (!_canComputeIntrinsics()) { return 0.0; } - _computeChildrenHeightWithMinIntrinsics(width); + _textPainter.setPlaceholderDimensions(layoutInlineChildren(width, ChildLayoutHelper.dryLayoutChild)); _layoutText(minWidth: width, maxWidth: width); return _textPainter.height; } @@ -486,84 +658,36 @@ class RenderParagraph extends RenderBox return _textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); } + /// Whether all inline widget children of this [RenderBox] support dry layout + /// calculation. + bool _canComputeDryLayoutForInlineWidgets() { + // Dry layout cannot be calculated without a full layout for + // alignments that require the baseline (baseline, aboveBaseline, + // belowBaseline). + return text.visitChildren((InlineSpan span) { + return (span is! PlaceholderSpan) || switch (span.alignment) { + ui.PlaceholderAlignment.baseline || + ui.PlaceholderAlignment.aboveBaseline || + ui.PlaceholderAlignment.belowBaseline => false, + ui.PlaceholderAlignment.top || + ui.PlaceholderAlignment.middle || + ui.PlaceholderAlignment.bottom => true, + }; + }); + } + + bool? _canComputeIntrinsicsCached; // Intrinsics cannot be calculated without a full layout for // alignments that require the baseline (baseline, aboveBaseline, // belowBaseline). bool _canComputeIntrinsics() { - for (final PlaceholderSpan span in _placeholderSpans) { - switch (span.alignment) { - case ui.PlaceholderAlignment.baseline: - case ui.PlaceholderAlignment.aboveBaseline: - case ui.PlaceholderAlignment.belowBaseline: - assert( - RenderObject.debugCheckingIntrinsics, - 'Intrinsics are not available for PlaceholderAlignment.baseline, ' - 'PlaceholderAlignment.aboveBaseline, or PlaceholderAlignment.belowBaseline.', - ); - return false; - case ui.PlaceholderAlignment.top: - case ui.PlaceholderAlignment.middle: - case ui.PlaceholderAlignment.bottom: - continue; - } - } - return true; - } - - void _computeChildrenWidthWithMaxIntrinsics(double height) { - RenderBox? child = firstChild; - final List placeholderDimensions = List.filled(childCount, PlaceholderDimensions.empty); - int childIndex = 0; - while (child != null) { - // Height and baseline is irrelevant as all text will be laid - // out in a single line. Therefore, using 0.0 as a dummy for the height. - placeholderDimensions[childIndex] = PlaceholderDimensions( - size: Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), - alignment: _placeholderSpans[childIndex].alignment, - baseline: _placeholderSpans[childIndex].baseline, + final bool returnValue = _canComputeIntrinsicsCached ??= _canComputeDryLayoutForInlineWidgets(); + assert( + returnValue || RenderObject.debugCheckingIntrinsics, + 'Intrinsics are not available for PlaceholderAlignment.baseline, ' + 'PlaceholderAlignment.aboveBaseline, or PlaceholderAlignment.belowBaseline.', ); - child = childAfter(child); - childIndex += 1; - } - _textPainter.setPlaceholderDimensions(placeholderDimensions); - } - - void _computeChildrenWidthWithMinIntrinsics(double height) { - RenderBox? child = firstChild; - final List placeholderDimensions = List.filled(childCount, PlaceholderDimensions.empty); - int childIndex = 0; - while (child != null) { - // Height and baseline is irrelevant; only looking for the widest word or - // placeholder. Therefore, using 0.0 as a dummy for height. - placeholderDimensions[childIndex] = PlaceholderDimensions( - size: Size(child.getMinIntrinsicWidth(double.infinity), 0.0), - alignment: _placeholderSpans[childIndex].alignment, - baseline: _placeholderSpans[childIndex].baseline, - ); - child = childAfter(child); - childIndex += 1; - } - _textPainter.setPlaceholderDimensions(placeholderDimensions); - } - - void _computeChildrenHeightWithMinIntrinsics(double width) { - RenderBox? child = firstChild; - final List placeholderDimensions = List.filled(childCount, PlaceholderDimensions.empty); - int childIndex = 0; - // Takes textScaleFactor into account because the content of the placeholder - // span will be scaled up when it paints. - width = width / textScaleFactor; - while (child != null) { - final Size size = child.getDryLayout(BoxConstraints(maxWidth: width)); - placeholderDimensions[childIndex] = PlaceholderDimensions( - size: size, - alignment: _placeholderSpans[childIndex].alignment, - baseline: _placeholderSpans[childIndex].baseline, - ); - child = childAfter(child); - childIndex += 1; - } - _textPainter.setPlaceholderDimensions(placeholderDimensions); + return returnValue; } @override @@ -571,48 +695,13 @@ class RenderParagraph extends RenderBox @override bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - // Hit test text spans. - 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; + final Object? span = _textPainter.text!.getSpanForPosition(textPosition); + if (span is HitTestTarget) { + result.add(HitTestEntry(span)); + return true; } - - // 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; + return hitTestInlineChildren(result, position); } bool _needsClipping = false; @@ -629,9 +718,7 @@ class RenderParagraph extends RenderBox final bool widthMatters = softWrap || overflow == TextOverflow.ellipsis; _textPainter.layout( minWidth: minWidth, - maxWidth: widthMatters ? - maxWidth : - double.infinity, + maxWidth: widthMatters ? maxWidth : double.infinity, ); } @@ -653,106 +740,15 @@ class RenderParagraph extends RenderBox _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); } - // 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) { - return []; - } - RenderBox? child = firstChild; - final List placeholderDimensions = List.filled(childCount, PlaceholderDimensions.empty); - 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!, - ); - case ui.PlaceholderAlignment.aboveBaseline: - case ui.PlaceholderAlignment.belowBaseline: - case ui.PlaceholderAlignment.bottom: - case ui.PlaceholderAlignment.middle: - case ui.PlaceholderAlignment.top: - baselineOffset = null; - } - } 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; - } - - // Iterate through the laid-out children and set the parentData offsets based - // off of the placeholders inserted for each child. - 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; - } - } - - 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()) { + if (!_canComputeIntrinsics()) { assert(debugCannotComputeDryLayout( reason: 'Dry layout not available for alignments that require baseline.', )); return Size.zero; } - _textPainter.setPlaceholderDimensions(_layoutChildren(constraints, dry: true)); + _textPainter.setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.dryLayoutChild)); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); return constraints.constrain(_textPainter.size); } @@ -760,9 +756,9 @@ class RenderParagraph extends RenderBox @override void performLayout() { final BoxConstraints constraints = this.constraints; - _placeholderDimensions = _layoutChildren(constraints); + _placeholderDimensions = layoutInlineChildren(constraints.maxWidth, ChildLayoutHelper.layoutChild); _layoutTextWithConstraints(constraints); - _setParentData(); + positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); // We grab _textPainter.size and _textPainter.didExceedMaxLines here because // assigning to `size` will trigger us to validate our intrinsic sizes, @@ -830,6 +826,11 @@ class RenderParagraph extends RenderBox } } + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + defaultApplyPaintTransform(child, transform); + } + @override void paint(PaintingContext context, Offset offset) { // Ideally we could compute the min/max intrinsic width/height with a @@ -866,30 +867,8 @@ class RenderParagraph extends RenderBox } _textPainter.paint(context.canvas, offset); - 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; + paintInlineChildren(context, offset); - final double scale = textParentData.scale!; - context.pushTransform( - needsCompositing, - offset + textParentData.offset, - Matrix4.diagonal3Values(scale, scale, scale), - (PaintingContext context, Offset offset) { - context.paintChild( - child!, - offset, - ); - }, - ); - child = childAfter(child); - childIndex += 1; - } if (_needsClipping) { if (_overflowShader != null) { context.canvas.translate(offset.dx, offset.dy); @@ -905,7 +884,6 @@ class RenderParagraph extends RenderBox fragment.paint(context, offset); } } - super.paint(context, offset); } /// Returns the offset at which to paint the caret. @@ -1155,15 +1133,8 @@ class RenderParagraph extends RenderBox children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { final SemanticsNode childNode = children.elementAt(childIndex); final TextParentData parentData = child!.parentData! as TextParentData; - assert(parentData.scale != null || parentData.offset == Offset.zero); // parentData.scale may be null if the render object is truncated. - if (parentData.scale != null) { - childNode.rect = Rect.fromLTWH( - childNode.rect.left, - childNode.rect.top, - childNode.rect.width * parentData.scale!, - childNode.rect.height * parentData.scale!, - ); + if (parentData.offset != null) { newChildren.add(childNode); } childIndex += 1; diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index bdc906be0e..19dbc89051 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -70,60 +70,40 @@ mixin RenderProxyBoxMixin on RenderBox, RenderObjectWithChi @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) { - if (child != null) { - return child!.getDistanceToActualBaseline(baseline); - } - return super.computeDistanceToActualBaseline(baseline); + return child?.getDistanceToActualBaseline(baseline) + ?? super.computeDistanceToActualBaseline(baseline); } @override Size computeDryLayout(BoxConstraints constraints) { - if (child != null) { - return child!.getDryLayout(constraints); - } - return computeSizeForNoChild(constraints); + return child?.getDryLayout(constraints) ?? computeSizeForNoChild(constraints); } @override void performLayout() { - if (child != null) { - child!.layout(constraints, parentUsesSize: true); - size = child!.size; - } else { - size = computeSizeForNoChild(constraints); - } + size = (child?..layout(constraints, parentUsesSize: true))?.size + ?? computeSizeForNoChild(constraints); + return; } /// Calculate the size the [RenderProxyBox] would have under the given @@ -142,9 +122,11 @@ mixin RenderProxyBoxMixin on RenderBox, RenderObjectWithChi @override void paint(PaintingContext context, Offset offset) { - if (child != null) { - context.paintChild(child!, offset); + final RenderBox? child = this.child; + if (child == null) { + return; } + context.paintChild(child, offset); } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index d1fc51b42c..d2151e9177 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5732,24 +5732,7 @@ class RichText extends MultiChildRenderObjectWidget { this.selectionColor, }) : assert(maxLines == null || maxLines > 0), assert(selectionRegistrar == null || selectionColor != null), - super(children: _extractChildren(text)); - - // Traverses the InlineSpan tree and depth-first collects the list of - // child widgets that are created in WidgetSpans. - static List _extractChildren(InlineSpan span) { - int index = 0; - final List result = []; - span.visitChildren((InlineSpan span) { - if (span is WidgetSpan) { - result.add(Semantics( - tagForChildren: PlaceholderSpanIndexSemanticsTag(index++), - child: span.child, - )); - } - return true; - }); - return result; - } + super(children: WidgetSpan.extractFromInlineSpan(text, textScaleFactor)); /// The text to display in this widget. final InlineSpan text; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 16c7dfc1a2..c8067f91d3 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4768,20 +4768,7 @@ class _Editable extends MultiChildRenderObjectWidget { this.promptRectRange, this.promptRectColor, required this.clipBehavior, - }) : super(children: _extractChildren(inlineSpan)); - - // 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; - } + }) : super(children: WidgetSpan.extractFromInlineSpan(inlineSpan, textScaleFactor)); final InlineSpan inlineSpan; final TextEditingValue value; diff --git a/packages/flutter/lib/src/widgets/widget_span.dart b/packages/flutter/lib/src/widgets/widget_span.dart index e175ee74b6..b0e5ad99b3 100644 --- a/packages/flutter/lib/src/widgets/widget_span.dart +++ b/packages/flutter/lib/src/widgets/widget_span.dart @@ -4,8 +4,10 @@ import 'dart:ui' as ui show ParagraphBuilder, PlaceholderAlignment; -import 'package:flutter/painting.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'basic.dart'; import 'framework.dart'; // Examples can assume: @@ -85,6 +87,39 @@ class WidgetSpan extends PlaceholderSpan { ), ); + /// Helper function for extracting [WidgetSpan]s in preorder, from the given + /// [InlineSpan] as a list of widgets. + /// + /// The `textScaleFactor` is the the number of font pixels for each logical + /// pixel. + /// + /// This function is used by [EditableText] and [RichText] so calling it + /// directly is rarely necessary. + static List extractFromInlineSpan(InlineSpan span, double textScaleFactor) { + final List widgets = []; + int index = 0; + // This assumes an InlineSpan tree's logical order is equivalent to preorder. + span.visitChildren((InlineSpan span) { + if (span is WidgetSpan) { + widgets.add( + _WidgetSpanParentData( + span: span, + child: Semantics( + tagForChildren: PlaceholderSpanIndexSemanticsTag(index++), + child: _AutoScaleInlineWidget(span: span, textScaleFactor: textScaleFactor, child: span.child), + ), + ), + ); + } + assert( + span is WidgetSpan || span is! PlaceholderSpan, + '$span is a PlaceholderSpan but not a WidgetSpan subclass. This is currently not supported.', + ); + return true; + }); + return widgets; + } + /// The widget to embed inline within text. final Widget child; @@ -110,7 +145,6 @@ class WidgetSpan extends PlaceholderSpan { currentDimensions.size.width, currentDimensions.size.height, alignment, - scale: textScaleFactor, baseline: currentDimensions.baseline, baselineOffset: currentDimensions.baselineOffset, ); @@ -212,4 +246,174 @@ class WidgetSpan extends PlaceholderSpan { // from being constructed. return true; } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('widget', child)); + } +} + +// A ParentDataWidget that sets TextParentData.span. +class _WidgetSpanParentData extends ParentDataWidget { + const _WidgetSpanParentData({ required this.span, required super.child }); + + final WidgetSpan span; + + @override + void applyParentData(RenderObject renderObject) { + final TextParentData parentData = renderObject.parentData! as TextParentData; + parentData.span = span; + } + + @override + Type get debugTypicalAncestorWidgetClass => RenderInlineChildrenContainerDefaults; +} + +// A RenderObjectWidget that automatically applies text scaling on inline +// widgets. +// +// TODO(LongCatIsLooong): this shouldn't happen automatically, at least there +// should be a way to opt out: https://github.com/flutter/flutter/issues/126962 +class _AutoScaleInlineWidget extends SingleChildRenderObjectWidget { + const _AutoScaleInlineWidget({ required this.span, required this.textScaleFactor, required super.child }); + + final WidgetSpan span; + final double textScaleFactor; + + @override + _RenderScaledInlineWidget createRenderObject(BuildContext context) { + return _RenderScaledInlineWidget(span.alignment, span.baseline, textScaleFactor); + } + + @override + void updateRenderObject(BuildContext context, _RenderScaledInlineWidget renderObject) { + renderObject + ..alignment = span.alignment + ..baseline = span.baseline + ..scale = textScaleFactor; + } +} + +class _RenderScaledInlineWidget extends RenderBox with RenderObjectWithChildMixin { + _RenderScaledInlineWidget(this._alignment, this._baseline, this._scale); + + double get scale => _scale; + double _scale; + set scale(double value) { + if (value == _scale) { + return; + } + assert(value > 0); + assert(value.isFinite); + _scale = value; + markNeedsLayout(); + } + + ui.PlaceholderAlignment get alignment => _alignment; + ui.PlaceholderAlignment _alignment; + set alignment(ui.PlaceholderAlignment value) { + if (_alignment == value) { + return; + } + _alignment = value; + markNeedsLayout(); + } + + TextBaseline? get baseline => _baseline; + TextBaseline? _baseline; + set baseline(TextBaseline? value) { + if (value == _baseline) { + return; + } + _baseline = value; + markNeedsLayout(); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return (child?.computeMaxIntrinsicHeight(width / scale) ?? 0.0) * scale; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return (child?.computeMaxIntrinsicWidth(height / scale) ?? 0.0) * scale; + } + + @override + double computeMinIntrinsicHeight(double width) { + return (child?.computeMinIntrinsicHeight(width / scale) ?? 0.0) * scale; + } + + @override + double computeMinIntrinsicWidth(double height) { + return (child?.computeMinIntrinsicWidth(height / scale) ?? 0.0) * scale; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return switch (child?.getDistanceToActualBaseline(baseline)) { + null => super.computeDistanceToActualBaseline(baseline), + final double childBaseline => scale * childBaseline, + }; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + assert(!constraints.hasBoundedHeight); + final Size unscaledSize = child?.computeDryLayout(BoxConstraints(maxWidth: constraints.maxWidth / scale)) ?? Size.zero; + return unscaledSize * scale; + } + + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + return; + } + assert(!constraints.hasBoundedHeight); + // Only constrain the width to the maximum width of the paragraph. + // Leave height unconstrained, which will overflow if expanded past. + child.layout(BoxConstraints(maxWidth: constraints.maxWidth / scale), parentUsesSize: true); + size = child.size * scale; + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.scale(scale, scale); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = this.child; + if (child == null) { + layer = null; + return; + } + if (scale == 1.0) { + context.paintChild(child, offset); + layer = null; + return; + } + layer = context.pushTransform( + needsCompositing, + offset, + Matrix4.diagonal3Values(scale, scale, 1.0), + (PaintingContext context, Offset offset) => context.paintChild(child, offset), + oldLayer: layer as TransformLayer? + ); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = this.child; + if (child == null) { + return false; + } + return result.addWithPaintTransform( + transform: Matrix4.diagonal3Values(scale, scale, 1.0), + position: position, + hitTest: (BoxHitTestResult result, Offset transformedOffset) => child.hitTest(result, position: transformedOffset), + ); + } } diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index dc2be0886f..0007d7a843 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -13,6 +13,25 @@ import 'mock_canvas.dart'; import 'recording_canvas.dart'; import 'rendering_tester.dart'; +void _applyParentData(List inlineRenderBoxes, InlineSpan span) { + int index = 0; + RenderBox? previousBox; + span.visitChildren((InlineSpan span) { + if (span is! WidgetSpan) { + return true; + } + + final RenderBox box = inlineRenderBoxes[index]; + box.parentData = TextParentData() + ..span = span + ..previousSibling = previousBox; + (previousBox?.parentData as TextParentData?)?.nextSibling = box; + index += 1; + previousBox = box; + return true; + }); +} + class _FakeEditableTextState with TextSelectionDelegate { @override TextEditingValue textEditingValue = TextEditingValue.empty; @@ -1327,7 +1346,7 @@ void main() { selection: const TextSelection.collapsed(offset: 3), children: renderBoxes, ); - + _applyParentData(renderBoxes, editable.text!); layout(editable); editable.hasFocus = true; pumpFrame(); @@ -1370,6 +1389,7 @@ void main() { children: renderBoxes, ); + _applyParentData(renderBoxes, editable.text!); layout(editable); editable.hasFocus = true; pumpFrame(); @@ -1415,6 +1435,7 @@ void main() { ); // Force a line wrap + _applyParentData(renderBoxes, editable.text!); layout(editable, constraints: const BoxConstraints(maxWidth: 75)); editable.hasFocus = true; pumpFrame(); @@ -1465,6 +1486,7 @@ void main() { ); // Force a line wrap + _applyParentData(renderBoxes, editable.text!); layout(editable, constraints: const BoxConstraints(maxWidth: 75)); editable.hasFocus = true; pumpFrame(); @@ -1520,6 +1542,7 @@ void main() { children: renderBoxes, ); + _applyParentData(renderBoxes, editable.text!); // Force a line wrap layout(editable, constraints: const BoxConstraints(maxWidth: 75)); editable.hasFocus = true; @@ -1554,6 +1577,7 @@ void main() { selectionColor: Colors.black, textDirection: TextDirection.ltr, cursorColor: Colors.red, + cursorWidth: 0.0, offset: viewportOffset, textSelectionDelegate: delegate, startHandleLayerLink: LayerLink(), @@ -1571,12 +1595,12 @@ void main() { textScaleFactor: 2.0, children: renderBoxes, ); + _applyParentData(renderBoxes, editable.text!); layout(editable, constraints: const BoxConstraints(maxWidth: screenWidth)); - editable.hasFocus = true; - final double maxIntrinsicWidth = editable.computeMaxIntrinsicWidth(fixedHeight); - pumpFrame(); - - expect(maxIntrinsicWidth, 278); + expect(editable.computeMaxIntrinsicWidth(fixedHeight), + 2.0 * 10.0 * 4 + 14.0 * 7 + 1.0, + reason: "intrinsic width = scale factor * width of 'test' + width of 'one two' + _caretMargin", + ); }); test('hits correct WidgetSpan when not scrolled', () { @@ -1613,6 +1637,7 @@ void main() { ), children: renderBoxes, ); + _applyParentData(renderBoxes, editable.text!); layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0))); // Prepare for painting after layout. pumpFrame(phase: EnginePhase.compositingBits); diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index 04d8b5760a..9c1e4ff3a3 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -14,6 +14,25 @@ import 'rendering_tester.dart'; const String _kText = "I polished up that handle so carefullee\nThat now I am the Ruler of the Queen's Navee!"; +void _applyParentData(List inlineRenderBoxes, InlineSpan span) { + int index = 0; + RenderBox? previousBox; + span.visitChildren((InlineSpan span) { + if (span is! WidgetSpan) { + return true; + } + + final RenderBox box = inlineRenderBoxes[index]; + box.parentData = TextParentData() + ..span = span + ..previousSibling = previousBox; + (previousBox?.parentData as TextParentData?)?.nextSibling = box; + index += 1; + previousBox = box; + return true; + }); +} + // A subclass of RenderParagraph that returns an empty list in getBoxesForSelection // for a given TextSelection. // This is intended to simulate SkParagraph's implementation of Paragraph.getBoxesForRange, @@ -504,6 +523,7 @@ void main() { textDirection: TextDirection.ltr, children: renderBoxes, ); + _applyParentData(renderBoxes, text); layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0)); final List boxes = paragraph.getBoxesForSelection( @@ -544,6 +564,7 @@ void main() { textDirection: TextDirection.ltr, children: renderBoxes, ); + _applyParentData(renderBoxes, text); layout(paragraph, constraints: const BoxConstraints(maxWidth: 100.0)); final List boxes = paragraph.getBoxesForSelection( @@ -559,91 +580,6 @@ void main() { expect(boxes[4], const TextBox.fromLTRBD(48.0, 0.0, 62.0, 14.0, TextDirection.ltr)); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020 - test('can compute IntrinsicHeight for widget span', () { - // Regression test for https://github.com/flutter/flutter/issues/59316 - const double screenWidth = 100.0; - const String sentence = 'one two'; - List renderBoxes = [ - RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr), - ]; - RenderParagraph paragraph = RenderParagraph( - const TextSpan( - children: [ - WidgetSpan(child: Text(sentence)), - ], - ), - children: renderBoxes, - textDirection: TextDirection.ltr, - ); - layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth)); - final double singleLineHeight = paragraph.computeMaxIntrinsicHeight(screenWidth); - expect(singleLineHeight, 14.0); - - pumpFrame(); - renderBoxes = [ - RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr), - ]; - paragraph = RenderParagraph( - const TextSpan( - children: [ - WidgetSpan(child: Text(sentence)), - ], - ), - textScaleFactor: 2.0, - children: renderBoxes, - textDirection: TextDirection.ltr, - ); - - layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth)); - final double maxIntrinsicHeight = paragraph.computeMaxIntrinsicHeight(screenWidth); - final double minIntrinsicHeight = paragraph.computeMinIntrinsicHeight(screenWidth); - // intrinsicHeight = singleLineHeight * textScaleFactor * two lines. - expect(maxIntrinsicHeight, singleLineHeight * 2.0 * 2); - expect(maxIntrinsicHeight, minIntrinsicHeight); - }); - - test('can compute IntrinsicWidth for widget span', () { - // 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'; - List renderBoxes = [ - RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr), - ]; - RenderParagraph paragraph = RenderParagraph( - const TextSpan( - children: [ - WidgetSpan(child: Text(sentence)), - ], - ), - children: renderBoxes, - textDirection: TextDirection.ltr, - ); - layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth)); - final double widthForOneLine = paragraph.computeMaxIntrinsicWidth(fixedHeight); - expect(widthForOneLine, 98.0); - - pumpFrame(); - renderBoxes = [ - RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr), - ]; - paragraph = RenderParagraph( - const TextSpan( - children: [ - WidgetSpan(child: Text(sentence)), - ], - ), - textScaleFactor: 2.0, - children: renderBoxes, - textDirection: TextDirection.ltr, - ); - - layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth)); - final double maxIntrinsicWidth = paragraph.computeMaxIntrinsicWidth(fixedHeight); - // maxIntrinsicWidth = widthForOneLine * textScaleFactor - expect(maxIntrinsicWidth, widthForOneLine * 2.0); - }); - test('inline widgets multiline test', () { const TextSpan text = TextSpan( text: 'a', @@ -676,6 +612,7 @@ void main() { textDirection: TextDirection.ltr, children: renderBoxes, ); + _applyParentData(renderBoxes, text); layout(paragraph, constraints: const BoxConstraints(maxWidth: 50.0)); final List boxes = paragraph.getBoxesForSelection( @@ -715,6 +652,7 @@ void main() { children: renderBoxes, textDirection: TextDirection.ltr, ); + _applyParentData(renderBoxes, paragraph.text); layout(paragraph, constraints: const BoxConstraints(maxWidth: screenWidth)); final SemanticsNode result = SemanticsNode(); final SemanticsNode truncatedChild = SemanticsNode(); @@ -815,6 +753,7 @@ void main() { children: renderBoxes, textDirection: TextDirection.ltr, ); + _applyParentData(renderBoxes, paragraph.text); layout(paragraph); final SemanticsNode node = SemanticsNode(); @@ -901,6 +840,7 @@ void main() { registrar: registrar, children: renderBoxes, ); + _applyParentData(renderBoxes, paragraph.text); layout(paragraph); // The widget span will register to the selection container without going // through the render paragraph. diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index ba727f123d..c273342640 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -997,7 +997,7 @@ void main() { TestSemantics( label: 'INTERRUPTION', textDirection: TextDirection.rtl, - rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 80.0), + rect: const Rect.fromLTRB(0.0, 0.0, 20.0, 40.0), ), TestSemantics( label: 'sky', @@ -1537,6 +1537,59 @@ void main() { expect(paragraph.getMinIntrinsicWidth(0.0), 200); }); + testWidgets('can compute intrinsic width and height for widget span with text scaling', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/59316 + const Key textKey = Key('RichText'); + Widget textWithNestedInlineSpans({ required double textScaleFactor, required double screenWidth }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: screenWidth, + child: RichText( + key: textKey, + textScaleFactor: textScaleFactor, + text: const TextSpan(children: [ + WidgetSpan(child: Text('one two')), + ]), + ), + ), + ), + ); + } + // The render object is going to be reused across widget tree rebuilds. + late final RenderParagraph outerParagraph = tester.renderObject(find.byKey(textKey)); + + await tester.pumpWidget(textWithNestedInlineSpans(textScaleFactor: 1.0, screenWidth: 100.0)); + expect( + outerParagraph.getMaxIntrinsicHeight(100.0), + 14.0, + reason: 'singleLineHeight = 14.0', + ); + + await tester.pumpWidget(textWithNestedInlineSpans(textScaleFactor: 2.0, screenWidth: 100.0)); + expect( + outerParagraph.getMinIntrinsicHeight(100.0), + 14.0 * 2.0 * 2, + reason: 'intrinsicHeight = singleLineHeight * textScaleFactor * two lines.', + ); + + await tester.pumpWidget(textWithNestedInlineSpans(textScaleFactor: 1.0, screenWidth: 1000.0)); + expect( + outerParagraph.getMaxIntrinsicWidth(1000.0), + 14.0 * 7, + reason: 'intrinsic width = 14.0 * 7', + ); + + await tester.pumpWidget(textWithNestedInlineSpans(textScaleFactor: 2.0, screenWidth: 1000.0)); + expect( + outerParagraph.getMaxIntrinsicWidth(1000.0), + 14.0 * 2.0 * 7, + reason: 'intrinsic width = glyph advance * textScaleFactor * num of glyphs', + ); + }); + testWidgets('Text uses TextStyle.overflow', (WidgetTester tester) async { const TextOverflow overflow = TextOverflow.fade;