Support WidgetSpan in RenderEditable (#83537)

This commit is contained in:
Gary Qian 2021-06-09 21:54:02 -07:00 committed by GitHub
parent 18b157886c
commit e70a1d1d7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 704 additions and 103 deletions

View file

@ -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<RenderBox, TextParentData>, RenderBoxContainerDefaultsMixin<RenderBox, TextParentData> {
/// 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<RenderBox>? 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<PlaceholderSpan> _placeholderSpans;
void _extractPlaceholderSpans(InlineSpan? span) {
_placeholderSpans = <PlaceholderSpan>[];
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<SemanticsNode> newChildCache = Queue<SemanticsNode>();
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<ui.TextBox> 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<ui.TextBox> 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>? _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<PlaceholderDimensions> _layoutChildren(BoxConstraints constraints, {bool dry = false}) {
if (childCount == 0) {
_textPainter.setPlaceholderDimensions(<PlaceholderDimensions>[]);
return <PlaceholderDimensions>[];
}
RenderBox? child = firstChild;
final List<PlaceholderDimensions> placeholderDimensions = List<PlaceholderDimensions>.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);
}

View file

@ -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<RenderBox> {
/// 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;

View file

@ -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<TextEditingValue> {
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: <TextSpan>[
@ -2651,7 +2651,7 @@ class EditableTextState extends State<EditableText> 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<EditableText> 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<Widget> _extractChildren(InlineSpan span) {
final List<Widget> result = <Widget>[];
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

View file

@ -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>{ 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>{
TargetPlatform.macOS,

View file

@ -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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('a')),
TextSpan(children: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
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<RenderBox> renderBoxes = <RenderBox>[
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: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('a')),
TextSpan(children: <InlineSpan>[
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<TextSpan>());
expect((target as TextSpan).text, 'test');
// Only testing the RenderEditable entry here once, not anymore below.
expect(result.path.last.target, isA<RenderEditable>());
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<TextSpan>());
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<TextSpan>());
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<TextSpan>());
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<TextSpan>());
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 {

View file

@ -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);
});
});