mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Make TextSpan
hit testing precise. (#139717)
Fixes https://github.com/flutter/flutter/issues/131435, #104594, #43400 Needs https://github.com/flutter/engine/pull/48774 (to fix the web test failure). Currently the method we use for text span hit testing `TextPainter.getPositionForOffset` always returns the closest `TextPosition`, even when the given offset is far away from the text. The new TextPaintes method tells you the layout bounds (`width = letterspacing / 2 + x_advance + letterspacing / 2`, `height = font ascent + font descent`) of a character, the PR changes the hit testing implementation such that a TextSpan is only considered hit if the point-down event landed in one of it's character's layout bounds. Potential issues: 1. In theory since the text is baseline aligned, we should use the max ascent and max descent of each character to calculate the height of the text span's hit-test region, in case some characters in the span have to fall back to a different font, but that will be slower and it typically doesn't make a huge difference. This is a breaking change. It also introduces a new finder and a new method `WidgetTester.tapOnText`: `await tester.tapOnText('string to match')` for ease of migration.
This commit is contained in:
parent
e86b825819
commit
ea5b97286e
@ -16,6 +16,7 @@ export 'dart:ui' show
|
||||
FontStyle,
|
||||
FontVariation,
|
||||
FontWeight,
|
||||
GlyphInfo,
|
||||
ImageShader,
|
||||
Locale,
|
||||
MaskFilter,
|
||||
|
@ -6,6 +6,7 @@ import 'dart:math' show max, min;
|
||||
import 'dart:ui' as ui show
|
||||
BoxHeightStyle,
|
||||
BoxWidthStyle,
|
||||
GlyphInfo,
|
||||
LineMetrics,
|
||||
Paragraph,
|
||||
ParagraphBuilder,
|
||||
@ -24,6 +25,7 @@ import 'strut_style.dart';
|
||||
import 'text_scaler.dart';
|
||||
import 'text_span.dart';
|
||||
|
||||
export 'dart:ui' show LineMetrics;
|
||||
export 'package:flutter/services.dart' show TextRange, TextSelection;
|
||||
|
||||
/// The default font size if none is specified.
|
||||
@ -1493,7 +1495,24 @@ class TextPainter {
|
||||
: boxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false);
|
||||
}
|
||||
|
||||
/// Returns the position within the text for the given pixel offset.
|
||||
/// Returns the [GlyphInfo] of the glyph closest to the given `offset` in the
|
||||
/// paragraph coordinate system, or null if the text is empty, or is entirely
|
||||
/// clipped or ellipsized away.
|
||||
///
|
||||
/// This method first finds the line closest to `offset.dy`, and then returns
|
||||
/// the [GlyphInfo] of the closest glyph(s) within that line.
|
||||
ui.GlyphInfo? getClosestGlyphForOffset(Offset offset) {
|
||||
assert(_debugAssertTextLayoutIsValid);
|
||||
assert(!_debugNeedsRelayout);
|
||||
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
|
||||
final ui.GlyphInfo? rawGlyphInfo = cachedLayout.paragraph.getClosestGlyphInfoForOffset(offset - cachedLayout.paintOffset);
|
||||
if (rawGlyphInfo == null || cachedLayout.paintOffset == Offset.zero) {
|
||||
return rawGlyphInfo;
|
||||
}
|
||||
return ui.GlyphInfo(rawGlyphInfo.graphemeClusterLayoutBounds.shift(cachedLayout.paintOffset), rawGlyphInfo.graphemeClusterCodeUnitRange, rawGlyphInfo.writingDirection);
|
||||
}
|
||||
|
||||
/// Returns the closest position within the text for the given pixel offset.
|
||||
TextPosition getPositionForOffset(Offset offset) {
|
||||
assert(_debugAssertTextLayoutIsValid);
|
||||
assert(!_debugNeedsRelayout);
|
||||
|
@ -343,18 +343,20 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
|
||||
/// Returns the text span that contains the given position in the text.
|
||||
@override
|
||||
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
|
||||
if (text == null) {
|
||||
final String? text = this.text;
|
||||
if (text == null || text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final TextAffinity affinity = position.affinity;
|
||||
final int targetOffset = position.offset;
|
||||
final int endOffset = offset.value + text!.length;
|
||||
final int endOffset = offset.value + text.length;
|
||||
|
||||
if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
|
||||
offset.value < targetOffset && targetOffset < endOffset ||
|
||||
endOffset == targetOffset && affinity == TextAffinity.upstream) {
|
||||
return this;
|
||||
}
|
||||
offset.increment(text!.length);
|
||||
offset.increment(text.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1941,8 +1941,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
|
||||
@protected
|
||||
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
||||
final Offset effectivePosition = position - _paintOffset;
|
||||
final InlineSpan? textSpan = _textPainter.text;
|
||||
switch (textSpan?.getSpanForPosition(_textPainter.getPositionForOffset(effectivePosition))) {
|
||||
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(effectivePosition);
|
||||
// The hit-test can't fall through the horizontal gaps between visually
|
||||
// adjacent characters on the same line, even with a large letter-spacing or
|
||||
// text justification, as graphemeClusterLayoutBounds.width is the advance
|
||||
// width to the next character, so there's no gap between their
|
||||
// graphemeClusterLayoutBounds rects.
|
||||
final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(effectivePosition)
|
||||
? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start))
|
||||
: null;
|
||||
switch (spanHit) {
|
||||
case final HitTestTarget span:
|
||||
result.add(HitTestEntry(span));
|
||||
return true;
|
||||
|
@ -303,6 +303,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
||||
}
|
||||
|
||||
static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit);
|
||||
|
||||
final TextPainter _textPainter;
|
||||
|
||||
List<AttributedString>? _cachedAttributedLabels;
|
||||
@ -730,9 +731,18 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
||||
bool hitTestSelf(Offset position) => true;
|
||||
|
||||
@override
|
||||
@protected
|
||||
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
||||
final TextPosition textPosition = _textPainter.getPositionForOffset(position);
|
||||
switch (_textPainter.text!.getSpanForPosition(textPosition)) {
|
||||
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position);
|
||||
// The hit-test can't fall through the horizontal gaps between visually
|
||||
// adjacent characters on the same line, even with a large letter-spacing or
|
||||
// text justification, as graphemeClusterLayoutBounds.width is the advance
|
||||
// width to the next character, so there's no gap between their
|
||||
// graphemeClusterLayoutBounds rects.
|
||||
final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(position)
|
||||
? _textPainter.text!.getSpanForPosition(TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start))
|
||||
: null;
|
||||
switch (spanHit) {
|
||||
case final HitTestTarget span:
|
||||
result.add(HitTestEntry(span));
|
||||
return true;
|
||||
|
@ -250,6 +250,24 @@ void main() {
|
||||
expect(textSpan2.compareTo(textSpan2), RenderComparison.identical);
|
||||
});
|
||||
|
||||
test('GetSpanForPosition', () {
|
||||
const TextSpan textSpan = TextSpan(
|
||||
text: '',
|
||||
children: <InlineSpan>[
|
||||
TextSpan(text: '', children: <InlineSpan>[
|
||||
TextSpan(text: 'a'),
|
||||
]),
|
||||
TextSpan(text: 'b'),
|
||||
TextSpan(text: 'c'),
|
||||
],
|
||||
);
|
||||
|
||||
expect((textSpan.getSpanForPosition(const TextPosition(offset: 0)) as TextSpan?)?.text, 'a');
|
||||
expect((textSpan.getSpanForPosition(const TextPosition(offset: 1)) as TextSpan?)?.text, 'b');
|
||||
expect((textSpan.getSpanForPosition(const TextPosition(offset: 2)) as TextSpan?)?.text, 'c');
|
||||
expect((textSpan.getSpanForPosition(const TextPosition(offset: 3)) as TextSpan?)?.text, isNull);
|
||||
});
|
||||
|
||||
test('GetSpanForPosition with WidgetSpan', () {
|
||||
const TextSpan textSpan = TextSpan(
|
||||
text: 'a',
|
||||
|
@ -12,6 +12,10 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'rendering_tester.dart';
|
||||
|
||||
double _caretMarginOf(RenderEditable renderEditable) {
|
||||
return renderEditable.cursorWidth + 1.0;
|
||||
}
|
||||
|
||||
void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
|
||||
int index = 0;
|
||||
RenderBox? previousBox;
|
||||
@ -1184,8 +1188,107 @@ void main() {
|
||||
});
|
||||
|
||||
group('hit testing', () {
|
||||
final TextSelectionDelegate delegate = _FakeEditableTextState();
|
||||
|
||||
test('Basic TextSpan Hit testing', () {
|
||||
final TextSpan textSpanA = TextSpan(text: 'A' * 10);
|
||||
const TextSpan textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0));
|
||||
|
||||
final TextSpan text = TextSpan(
|
||||
text: '',
|
||||
style: const TextStyle(fontSize: 10.0),
|
||||
children: <InlineSpan>[textSpanA, textSpanBC],
|
||||
);
|
||||
|
||||
final RenderEditable renderEditable = RenderEditable(
|
||||
text: text,
|
||||
maxLines: null,
|
||||
startHandleLayerLink: LayerLink(),
|
||||
endHandleLayerLink: LayerLink(),
|
||||
textDirection: TextDirection.ltr,
|
||||
offset: ViewportOffset.fixed(0.0),
|
||||
textSelectionDelegate: delegate,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
layout(renderEditable, constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)));
|
||||
|
||||
BoxHitTestResult result;
|
||||
|
||||
// Hit-testing the first line
|
||||
// First A
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
// The last A.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
// Far away from the line.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
|
||||
// Hit-testing the second line
|
||||
// Tapping on B (startX = letter-spacing / 2 = 13.0).
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// Between B and C, with large letter-spacing.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// On C.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// After C.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
|
||||
// Not even remotely close.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
});
|
||||
|
||||
test('TextSpan Hit testing with text justification', () {
|
||||
const TextSpan textSpanA = TextSpan(text: 'A '); // The space is a word break.
|
||||
const TextSpan textSpanB = TextSpan(text: 'B\u200B'); // The zero-width space is used as a line break.
|
||||
final TextSpan textSpanC = TextSpan(text: 'C' * 10); // The third span starts a new line since it's too long for the first line.
|
||||
|
||||
// The text should look like:
|
||||
// A B
|
||||
// CCCCCCCCCC
|
||||
final TextSpan text = TextSpan(
|
||||
text: '',
|
||||
style: const TextStyle(fontSize: 10.0),
|
||||
children: <InlineSpan>[textSpanA, textSpanB, textSpanC],
|
||||
);
|
||||
final RenderEditable renderEditable = RenderEditable(
|
||||
text: text,
|
||||
maxLines: null,
|
||||
startHandleLayerLink: LayerLink(),
|
||||
endHandleLayerLink: LayerLink(),
|
||||
textDirection: TextDirection.ltr,
|
||||
textAlign: TextAlign.justify,
|
||||
offset: ViewportOffset.fixed(0.0),
|
||||
textSelectionDelegate: delegate,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
|
||||
layout(renderEditable, constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)));
|
||||
BoxHitTestResult result;
|
||||
|
||||
// Tapping on A.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
|
||||
// Between A and B.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
|
||||
// On B.
|
||||
expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanB]);
|
||||
});
|
||||
|
||||
test('hits correct TextSpan when not scrolled', () {
|
||||
final TextSelectionDelegate delegate = _FakeEditableTextState();
|
||||
final RenderEditable editable = RenderEditable(
|
||||
text: const TextSpan(
|
||||
style: TextStyle(height: 1.0, fontSize: 10.0),
|
||||
@ -1692,7 +1795,8 @@ void main() {
|
||||
// Prepare for painting after layout.
|
||||
pumpFrame(phase: EnginePhase.compositingBits);
|
||||
BoxHitTestResult result = BoxHitTestResult();
|
||||
editable.hitTest(result, position: Offset.zero);
|
||||
// The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
|
||||
editable.hitTest(result, position: const Offset(1.0, 5.0));
|
||||
// We expect two hit test entries in the path because the RenderEditable
|
||||
// will add itself as well.
|
||||
expect(result.path, hasLength(2));
|
||||
@ -1702,7 +1806,7 @@ void main() {
|
||||
// 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));
|
||||
editable.hitTest(result, position: const Offset(15.0, 5.0));
|
||||
expect(result.path, hasLength(2));
|
||||
target = result.path.first.target;
|
||||
expect(target, isA<TextSpan>());
|
||||
@ -1775,7 +1879,8 @@ void main() {
|
||||
// Prepare for painting after layout.
|
||||
pumpFrame(phase: EnginePhase.compositingBits);
|
||||
BoxHitTestResult result = BoxHitTestResult();
|
||||
editable.hitTest(result, position: Offset.zero);
|
||||
// The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
|
||||
editable.hitTest(result, position: const Offset(0.0, 4.0));
|
||||
// We expect two hit test entries in the path because the RenderEditable
|
||||
// will add itself as well.
|
||||
expect(result.path, hasLength(2));
|
||||
@ -1785,13 +1890,14 @@ void main() {
|
||||
// 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));
|
||||
editable.hitTest(result, position: const Offset(15.0, 4.0));
|
||||
expect(result.path, hasLength(2));
|
||||
target = result.path.first.target;
|
||||
expect(target, isA<TextSpan>());
|
||||
expect((target as TextSpan).text, text);
|
||||
|
||||
result = BoxHitTestResult();
|
||||
// "test" is 40 pixel wide.
|
||||
editable.hitTest(result, position: const Offset(41.0, 0.0));
|
||||
expect(result.path, hasLength(3));
|
||||
target = result.path.first.target;
|
||||
@ -1814,7 +1920,7 @@ void main() {
|
||||
|
||||
result = BoxHitTestResult();
|
||||
editable.hitTest(result, position: const Offset(5.0, 15.0));
|
||||
expect(result.path, hasLength(2));
|
||||
expect(result.path, hasLength(1)); // Only the RenderEditable.
|
||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020
|
||||
});
|
||||
|
||||
|
@ -761,6 +761,84 @@ void main() {
|
||||
expect(node.childrenCount, 2);
|
||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020
|
||||
|
||||
test('Basic TextSpan Hit testing', () {
|
||||
final TextSpan textSpanA = TextSpan(text: 'A' * 10);
|
||||
const TextSpan textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0));
|
||||
|
||||
final TextSpan text = TextSpan(
|
||||
style: const TextStyle(fontSize: 10.0),
|
||||
children: <InlineSpan>[textSpanA, textSpanBC],
|
||||
);
|
||||
|
||||
final RenderParagraph paragraph = RenderParagraph(text, textDirection: TextDirection.ltr);
|
||||
layout(paragraph, constraints: const BoxConstraints.tightFor(width: 100.0));
|
||||
|
||||
BoxHitTestResult result;
|
||||
|
||||
// Hit-testing the first line
|
||||
// First A
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
// The last A.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
// Far away from the line.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
|
||||
// Hit-testing the second line
|
||||
// Tapping on B (startX = letter-spacing / 2 = 13.0).
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// Between B and C, with large letter-spacing.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// On C.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanBC]);
|
||||
|
||||
// After C.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isFalse);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
|
||||
// Not even remotely close.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[]);
|
||||
});
|
||||
|
||||
test('TextSpan Hit testing with text justification', () {
|
||||
const TextSpan textSpanA = TextSpan(text: 'A '); // The space is a word break.
|
||||
const TextSpan textSpanB = TextSpan(text: 'B\u200B'); // The zero-width space is used as a line break.
|
||||
final TextSpan textSpanC = TextSpan(text: 'C' * 10); // The third span starts a new line since it's too long for the first line.
|
||||
|
||||
// The text should look like:
|
||||
// A B
|
||||
// CCCCCCCCCC
|
||||
final TextSpan text = TextSpan(
|
||||
text: '',
|
||||
style: const TextStyle(fontSize: 10.0),
|
||||
children: <InlineSpan>[textSpanA, textSpanB, textSpanC],
|
||||
);
|
||||
|
||||
final RenderParagraph paragraph = RenderParagraph(text, textDirection: TextDirection.ltr, textAlign: TextAlign.justify);
|
||||
layout(paragraph, constraints: const BoxConstraints.tightFor(width: 100.0));
|
||||
BoxHitTestResult result;
|
||||
|
||||
// Tapping on A.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
|
||||
// Between A and B.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanA]);
|
||||
|
||||
// On B.
|
||||
expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue);
|
||||
expect(result.path.map((HitTestEntry<HitTestTarget> entry) => entry.target).whereType<TextSpan>(), <TextSpan>[textSpanB]);
|
||||
});
|
||||
|
||||
group('Selection', () {
|
||||
void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) {
|
||||
for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) {
|
||||
|
@ -2,6 +2,7 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -15,7 +16,6 @@ class _MockRenderSliver extends RenderSliver {
|
||||
maxPaintExtent: 10,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Future<void> test(WidgetTester tester, double offset, EdgeInsetsGeometry padding, AxisDirection axisDirection, TextDirection textDirection) {
|
||||
@ -180,15 +180,15 @@ void main() {
|
||||
]);
|
||||
HitTestResult result;
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'before');
|
||||
hitsText(result, 'before');
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 60.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(100.0, 100.0));
|
||||
expectIsTextSpan(result.path.first.target, 'padded');
|
||||
hitsText(result, 'padded');
|
||||
result = tester.hitTestOnBinding(const Offset(100.0, 490.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 520.0));
|
||||
expectIsTextSpan(result.path.first.target, 'after');
|
||||
hitsText(result, 'after');
|
||||
});
|
||||
|
||||
testWidgets('Viewport+SliverPadding hit testing up', (WidgetTester tester) async {
|
||||
@ -202,15 +202,15 @@ void main() {
|
||||
]);
|
||||
HitTestResult result;
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'before');
|
||||
hitsText(result, 'before');
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-60.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(100.0, 600.0-100.0));
|
||||
expectIsTextSpan(result.path.first.target, 'padded');
|
||||
hitsText(result, 'padded');
|
||||
result = tester.hitTestOnBinding(const Offset(100.0, 600.0-490.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-520.0));
|
||||
expectIsTextSpan(result.path.first.target, 'after');
|
||||
hitsText(result, 'after');
|
||||
});
|
||||
|
||||
testWidgets('Viewport+SliverPadding hit testing left', (WidgetTester tester) async {
|
||||
@ -224,15 +224,15 @@ void main() {
|
||||
]);
|
||||
HitTestResult result;
|
||||
result = tester.hitTestOnBinding(const Offset(800.0-10.0, 10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'before');
|
||||
hitsText(result, 'before');
|
||||
result = tester.hitTestOnBinding(const Offset(800.0-60.0, 10.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(800.0-100.0, 100.0));
|
||||
expectIsTextSpan(result.path.first.target, 'padded');
|
||||
hitsText(result, 'padded');
|
||||
result = tester.hitTestOnBinding(const Offset(800.0-490.0, 100.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(800.0-520.0, 10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'after');
|
||||
hitsText(result, 'after');
|
||||
});
|
||||
|
||||
testWidgets('Viewport+SliverPadding hit testing right', (WidgetTester tester) async {
|
||||
@ -246,15 +246,15 @@ void main() {
|
||||
]);
|
||||
HitTestResult result;
|
||||
result = tester.hitTestOnBinding(const Offset(10.0, 10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'before');
|
||||
hitsText(result, 'before');
|
||||
result = tester.hitTestOnBinding(const Offset(60.0, 10.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(100.0, 100.0));
|
||||
expectIsTextSpan(result.path.first.target, 'padded');
|
||||
hitsText(result, 'padded');
|
||||
result = tester.hitTestOnBinding(const Offset(490.0, 100.0));
|
||||
expect(result.path.first.target, isA<RenderView>());
|
||||
result = tester.hitTestOnBinding(const Offset(520.0, 10.0));
|
||||
expectIsTextSpan(result.path.first.target, 'after');
|
||||
hitsText(result, 'after');
|
||||
});
|
||||
|
||||
testWidgets('Viewport+SliverPadding no child', (WidgetTester tester) async {
|
||||
@ -617,7 +617,15 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
void expectIsTextSpan(Object target, String text) {
|
||||
expect(target, isA<TextSpan>());
|
||||
expect((target as TextSpan).text, text);
|
||||
void hitsText(HitTestResult hitTestResult, String text) {
|
||||
switch (hitTestResult.path.first.target) {
|
||||
case final TextSpan span:
|
||||
expect(span.text, text);
|
||||
case final RenderParagraph paragraph:
|
||||
final InlineSpan span = paragraph.text;
|
||||
expect(span, isA<TextSpan>());
|
||||
expect((span as TextSpan).text, text);
|
||||
case final HitTestTarget target:
|
||||
fail('$target is not a TextSpan or a RenderParagraph.');
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,23 @@ const double kDragSlopDefault = 20.0;
|
||||
|
||||
const String _defaultPlatform = kIsWeb ? 'web' : 'android';
|
||||
|
||||
// Finds the end index (exclusive) of the span at `startIndex`, or `endIndex` if
|
||||
// there are no other spans between `startIndex` and `endIndex`.
|
||||
// The InlineSpan protocol doesn't expose the length of the span so we'll
|
||||
// have to iterate through the whole range.
|
||||
(InlineSpan, int)? _findEndOfSpan(InlineSpan rootSpan, int startIndex, int endIndex) {
|
||||
assert(endIndex > startIndex);
|
||||
final InlineSpan? subspan = rootSpan.getSpanForPosition(TextPosition(offset: startIndex));
|
||||
if (subspan == null) {
|
||||
return null;
|
||||
}
|
||||
int i = startIndex + 1;
|
||||
while (i < endIndex && rootSpan.getSpanForPosition(TextPosition(offset: i)) == subspan) {
|
||||
i += 1;
|
||||
}
|
||||
return (subspan, i);
|
||||
}
|
||||
|
||||
// Examples can assume:
|
||||
// typedef MyWidget = Placeholder;
|
||||
|
||||
@ -997,6 +1014,47 @@ abstract class WidgetController {
|
||||
return tapAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), pointer: pointer, buttons: buttons, kind: kind);
|
||||
}
|
||||
|
||||
/// Dispatch a pointer down / pointer up sequence at a hit-testable
|
||||
/// [InlineSpan] (typically a [TextSpan]) within the given text range.
|
||||
///
|
||||
/// This method performs a more spatially precise tap action on a piece of
|
||||
/// static text, than the widget-based [tap] method.
|
||||
///
|
||||
/// The given [Finder] must find one and only one matching substring, and the
|
||||
/// substring must be hit-testable (meaning, it must not be off-screen, or be
|
||||
/// obscured by other widgets, or in a disabled widget). Otherwise this method
|
||||
/// throws a [FlutterError].
|
||||
///
|
||||
/// If the target substring contains more than one hit-testable [InlineSpan]s,
|
||||
/// [tapOnText] taps on one of them, but does not guarantee which.
|
||||
///
|
||||
/// The `pointer` and `button` arguments specify [PointerEvent.pointer] and
|
||||
/// [PointerEvent.buttons] of the tap event.
|
||||
Future<void> tapOnText(finders.FinderBase<finders.TextRangeContext> textRangeFinder, {int? pointer, int buttons = kPrimaryButton }) {
|
||||
final Iterable<finders.TextRangeContext> ranges = textRangeFinder.evaluate();
|
||||
if (ranges.isEmpty) {
|
||||
throw FlutterError(textRangeFinder.toString());
|
||||
}
|
||||
if (ranges.length > 1) {
|
||||
throw FlutterError(
|
||||
'$textRangeFinder. The "tapOnText" method needs a single non-empty TextRange.',
|
||||
);
|
||||
}
|
||||
final Offset? tapLocation = _findHitTestableOffsetIn(ranges.single);
|
||||
if (tapLocation == null) {
|
||||
final finders.TextRangeContext found = textRangeFinder.evaluate().single;
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('Finder specifies a TextRange that can not receive pointer events.'),
|
||||
ErrorDescription('The finder used was: ${textRangeFinder.toString(describeSelf: true)}'),
|
||||
ErrorDescription('Found a matching substring in a static text widget, within ${found.textRange}.'),
|
||||
ErrorDescription('But the "tapOnText" method could not find a hit-testable Offset with in that text range.'),
|
||||
found.renderObject.toDiagnosticsNode(name: 'The RenderBox of that static text widget was', style: DiagnosticsTreeStyle.shallow),
|
||||
]
|
||||
);
|
||||
}
|
||||
return tapAt(tapLocation, pointer: pointer, buttons: buttons);
|
||||
}
|
||||
|
||||
/// Dispatch a pointer down / pointer up sequence at the given location.
|
||||
Future<void> tapAt(
|
||||
Offset location, {
|
||||
@ -1762,6 +1820,45 @@ abstract class WidgetController {
|
||||
/// in the documentation for the [flutter_test] library.
|
||||
static bool hitTestWarningShouldBeFatal = false;
|
||||
|
||||
/// Finds one hit-testable Offset in the given `textRangeContext`'s render
|
||||
/// object.
|
||||
Offset? _findHitTestableOffsetIn(finders.TextRangeContext textRangeContext) {
|
||||
TestAsyncUtils.guardSync();
|
||||
final TextRange range = textRangeContext.textRange;
|
||||
assert(range.isNormalized);
|
||||
assert(range.isValid);
|
||||
final Offset renderParagraphPaintOffset = textRangeContext.renderObject.localToGlobal(Offset.zero);
|
||||
assert(renderParagraphPaintOffset.isFinite);
|
||||
|
||||
int spanStart = range.start;
|
||||
while (spanStart < range.end) {
|
||||
switch (_findEndOfSpan(textRangeContext.renderObject.text, spanStart, range.end)) {
|
||||
case (final HitTestTarget target, final int endIndex):
|
||||
// Uses BoxHeightStyle.tight in getBoxesForSelection to make sure the
|
||||
// returned boxes don't extend outside of the hit-testable region.
|
||||
final Iterable<Offset> testOffsets = textRangeContext.renderObject
|
||||
.getBoxesForSelection(TextSelection(baseOffset: spanStart, extentOffset: endIndex))
|
||||
// Try hit-testing the center of each TextBox.
|
||||
.map((TextBox textBox) => textBox.toRect().center);
|
||||
|
||||
for (final Offset localOffset in testOffsets) {
|
||||
final HitTestResult result = HitTestResult();
|
||||
final Offset globalOffset = localOffset + renderParagraphPaintOffset;
|
||||
binding.hitTestInView(result, globalOffset, textRangeContext.view.view.viewId);
|
||||
if (result.path.any((HitTestEntry entry) => entry.target == target)) {
|
||||
return globalOffset;
|
||||
}
|
||||
}
|
||||
spanStart = endIndex;
|
||||
case (_, final int endIndex):
|
||||
spanStart = endIndex;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Offset _getElementPoint(finders.FinderBase<Element> finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) {
|
||||
TestAsyncUtils.guardSync();
|
||||
final Iterable<Element> elements = finder.evaluate();
|
||||
@ -1791,17 +1888,10 @@ abstract class WidgetController {
|
||||
final FlutterView view = _viewOf(finder);
|
||||
final HitTestResult result = HitTestResult();
|
||||
binding.hitTestInView(result, location, view.viewId);
|
||||
bool found = false;
|
||||
for (final HitTestEntry entry in result.path) {
|
||||
if (entry.target == box) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final bool found = result.path.any((HitTestEntry entry) => entry.target == box);
|
||||
if (!found) {
|
||||
final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view);
|
||||
bool outOfBounds = false;
|
||||
outOfBounds = !(Offset.zero & renderView.size).contains(location);
|
||||
final bool outOfBounds = !(Offset.zero & renderView.size).contains(location);
|
||||
if (hitTestWarningShouldBeFatal) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('Finder specifies a widget that would not receive pointer events.'),
|
||||
|
@ -23,6 +23,26 @@ typedef SemanticsNodePredicate = bool Function(SemanticsNode node);
|
||||
/// Signature for [FinderBase.describeMatch].
|
||||
typedef DescribeMatchCallback = String Function(Plurality plurality);
|
||||
|
||||
/// The `CandidateType` of finders that search for and filter subtrings,
|
||||
/// within static text rendered by [RenderParagraph]s.
|
||||
final class TextRangeContext {
|
||||
const TextRangeContext._(this.view, this.renderObject, this.textRange);
|
||||
|
||||
/// The [View] containing the static text.
|
||||
///
|
||||
/// This is used for hit-testing.
|
||||
final View view;
|
||||
|
||||
/// The RenderObject that contains the static text.
|
||||
final RenderParagraph renderObject;
|
||||
|
||||
/// The [TextRange] of the subtring within [renderObject]'s text.
|
||||
final TextRange textRange;
|
||||
|
||||
@override
|
||||
String toString() => 'TextRangeContext($view, $renderObject, $textRange)';
|
||||
}
|
||||
|
||||
/// Some frequently used [Finder]s and [SemanticsFinder]s.
|
||||
const CommonFinders find = CommonFinders._();
|
||||
|
||||
@ -42,6 +62,9 @@ class CommonFinders {
|
||||
/// Some frequently used semantics finders.
|
||||
CommonSemanticsFinders get semantics => const CommonSemanticsFinders._();
|
||||
|
||||
/// Some frequently used text range finders.
|
||||
CommonTextRangeFinders get textRange => const CommonTextRangeFinders._();
|
||||
|
||||
/// Finds [Text], [EditableText], and optionally [RichText] widgets
|
||||
/// containing string equal to the `text` argument.
|
||||
///
|
||||
@ -677,6 +700,35 @@ class CommonSemanticsFinders {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides lightweight syntax for getting frequently used text range finders.
|
||||
///
|
||||
/// This class is instantiated once, as [CommonFinders.textRange], under [find].
|
||||
final class CommonTextRangeFinders {
|
||||
const CommonTextRangeFinders._();
|
||||
|
||||
/// Finds all non-overlapping occurrences of the given `substring` in the
|
||||
/// static text widgets and returns the [TextRange]s.
|
||||
///
|
||||
/// If the `skipOffstage` argument is true (the default), then this skips
|
||||
/// static text inside widgets that are [Offstage], or that are from inactive
|
||||
/// [Route]s.
|
||||
///
|
||||
/// If the `descendentOf` argument is non-null, this method only searches in
|
||||
/// the descendants of that parameter for the given substring.
|
||||
///
|
||||
/// This finder uses the [Pattern.allMatches] method to match the substring in
|
||||
/// the text. After finding a matching substring in the text, the method
|
||||
/// continues the search from the end of the match, thus skipping overlapping
|
||||
/// occurrences of the substring.
|
||||
FinderBase<TextRangeContext> ofSubstring(String substring, { bool skipOffstage = true, FinderBase<Element>? descendentOf }) {
|
||||
final _TextContainingWidgetFinder textWidgetFinder = _TextContainingWidgetFinder(substring, skipOffstage: skipOffstage, findRichText: true);
|
||||
final Finder elementFinder = descendentOf == null
|
||||
? textWidgetFinder
|
||||
: _DescendantWidgetFinder(descendentOf, textWidgetFinder, matchRoot: true, skipOffstage: skipOffstage);
|
||||
return _StaticTextRangeFinder(elementFinder, substring);
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes how a string of text should be pluralized.
|
||||
enum Plurality {
|
||||
/// Text should be pluralized to describe zero items.
|
||||
@ -998,7 +1050,7 @@ abstract class Finder extends FinderBase<Element> with _LegacyFinderMixin {
|
||||
@override
|
||||
String describeMatch(Plurality plurality) {
|
||||
return switch (plurality) {
|
||||
Plurality.zero ||Plurality.many => 'widgets with $description',
|
||||
Plurality.zero || Plurality.many => 'widgets with $description',
|
||||
Plurality.one => 'widget with $description',
|
||||
};
|
||||
}
|
||||
@ -1026,6 +1078,61 @@ abstract class SemanticsFinder extends FinderBase<SemanticsNode> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A base class for creating finders that search for static text rendered by a
|
||||
/// [RenderParagraph].
|
||||
class _StaticTextRangeFinder extends FinderBase<TextRangeContext> {
|
||||
/// Creates a new [_StaticTextRangeFinder] that searches for the given
|
||||
/// `pattern` in the [Element]s found by `_parent`.
|
||||
_StaticTextRangeFinder(this._parent, this.pattern);
|
||||
|
||||
final FinderBase<Element> _parent;
|
||||
final Pattern pattern;
|
||||
|
||||
Iterable<TextRangeContext> _flatMap(Element from) {
|
||||
final RenderObject? renderObject = from.renderObject;
|
||||
// This is currently only exposed on text matchers. Only consider RenderBoxes.
|
||||
if (renderObject is! RenderBox) {
|
||||
return const Iterable<TextRangeContext>.empty();
|
||||
}
|
||||
|
||||
final View view = from.findAncestorWidgetOfExactType<View>()!;
|
||||
final List<RenderParagraph> paragraphs = <RenderParagraph>[];
|
||||
|
||||
void visitor(RenderObject child) {
|
||||
switch (child) {
|
||||
case RenderParagraph():
|
||||
paragraphs.add(child);
|
||||
// No need to continue, we are piggybacking off of a text matcher, so
|
||||
// inline text widgets will be reported separately.
|
||||
case RenderBox():
|
||||
child.visitChildren(visitor);
|
||||
case _:
|
||||
}
|
||||
}
|
||||
visitor(renderObject);
|
||||
Iterable<TextRangeContext> searchInParagraph(RenderParagraph paragraph) {
|
||||
final String text = paragraph.text.toPlainText(includeSemanticsLabels: false);
|
||||
return pattern.allMatches(text)
|
||||
.map((Match match) => TextRangeContext._(view, paragraph, TextRange(start: match.start, end: match.end)));
|
||||
}
|
||||
return paragraphs.expand(searchInParagraph);
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<TextRangeContext> findInCandidates(Iterable<TextRangeContext> candidates) => candidates;
|
||||
|
||||
@override
|
||||
Iterable<TextRangeContext> get allCandidates => _parent.evaluate().expand(_flatMap);
|
||||
|
||||
@override
|
||||
String describeMatch(Plurality plurality) {
|
||||
return switch (plurality) {
|
||||
Plurality.zero || Plurality.many => 'non-overlapping TextRanges that match the Pattern "$pattern"',
|
||||
Plurality.one => 'non-overlapping TextRange that matches the Pattern "$pattern"',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A mixin that applies additional filtering to the results of a parent [Finder].
|
||||
mixin ChainedFinderMixin<CandidateType> on FinderBase<CandidateType> {
|
||||
|
||||
|
@ -1482,6 +1482,172 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('WidgetTester.tapOnText', () {
|
||||
final List<String > tapLogs = <String>[];
|
||||
final TapGestureRecognizer tapA = TapGestureRecognizer()..onTap = () { tapLogs.add('A'); };
|
||||
final TapGestureRecognizer tapB = TapGestureRecognizer()..onTap = () { tapLogs.add('B'); };
|
||||
final TapGestureRecognizer tapC = TapGestureRecognizer()..onTap = () { tapLogs.add('C'); };
|
||||
tearDown(tapLogs.clear);
|
||||
tearDownAll(() {
|
||||
tapA.dispose();
|
||||
tapB.dispose();
|
||||
tapC.dispose();
|
||||
});
|
||||
|
||||
testWidgets('basic test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Text.rich(TextSpan(text: 'match', recognizer: tapA)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tapOnText(find.textRange.ofSubstring('match'));
|
||||
expect(tapLogs, <String>['A']);
|
||||
});
|
||||
|
||||
testWidgets('partially obstructed: find a hit-testable Offset', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
Positioned(
|
||||
left: 100.0 - 9 * 10.0, // Only the last character is visible.
|
||||
child: Text.rich(TextSpan(text: 'text match', style: const TextStyle(fontSize: 10), recognizer: tapA)),
|
||||
),
|
||||
const Positioned(
|
||||
left: 0.0,
|
||||
right: 100.0,
|
||||
child: MetaData(behavior: HitTestBehavior.opaque),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => tester.tapOnText(find.textRange.ofSubstring('text match')),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('multiline text partially obstructed: find a hit-testable Offset', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
Positioned(
|
||||
width: 100.0,
|
||||
top: 23.0,
|
||||
left: 0.0,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 10),
|
||||
children: <InlineSpan>[
|
||||
TextSpan(text: 'AAAAAAAAA ', recognizer: tapA),
|
||||
TextSpan(text: 'BBBBBBBBB ', recognizer: tapB), // The only visible line
|
||||
TextSpan(text: 'CCCCCCCCC ', recognizer: tapC),
|
||||
]
|
||||
)
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 23.0, // Some random offset to test the global to local Offset conversion
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
height: 10.0,
|
||||
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
|
||||
),
|
||||
const Positioned(
|
||||
top: 43.0,
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
height: 10.0,
|
||||
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tapOnText(find.textRange.ofSubstring('AAAAAAAAA BBBBBBBBB CCCCCCCCC '));
|
||||
expect(tapLogs, <String>['B']);
|
||||
});
|
||||
|
||||
testWidgets('error message: no matching text', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await expectLater(
|
||||
() => tester.tapOnText(find.textRange.ofSubstring('nonexistent')),
|
||||
throwsA(isFlutterError.having(
|
||||
(FlutterError error) => error.message,
|
||||
'message',
|
||||
contains('Found 0 non-overlapping TextRanges that match the Pattern "nonexistent": []'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('error message: too many matches', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'match',
|
||||
recognizer: tapA,
|
||||
children: <InlineSpan>[TextSpan(text: 'another match', recognizer: tapB)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => tester.tapOnText(find.textRange.ofSubstring('match')),
|
||||
throwsA(isFlutterError.having(
|
||||
(FlutterError error) => error.message,
|
||||
'message',
|
||||
stringContainsInOrder(<String>[
|
||||
'Found 2 non-overlapping TextRanges that match the Pattern "match"',
|
||||
'TextRange(start: 0, end: 5)',
|
||||
'TextRange(start: 13, end: 18)',
|
||||
'The "tapOnText" method needs a single non-empty TextRange.',
|
||||
])
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('error message: not hit-testable', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
Text.rich(TextSpan(text: 'match', recognizer: tapA)),
|
||||
const MetaData(behavior: HitTestBehavior.opaque),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => tester.tapOnText(find.textRange.ofSubstring('match')),
|
||||
throwsA(isFlutterError.having(
|
||||
(FlutterError error) => error.message,
|
||||
'message',
|
||||
stringContainsInOrder(<String>[
|
||||
'The finder used was: A finder that searches for non-overlapping TextRanges that match the Pattern "match".',
|
||||
'Found a matching substring in a static text widget, within TextRange(start: 0, end: 5).',
|
||||
'But the "tapOnText" method could not find a hit-testable Offset with in that text range.',
|
||||
])
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _SemanticsTestWidget extends StatelessWidget {
|
||||
|
@ -331,6 +331,100 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('text range finders', () {
|
||||
testWidgets('basic text span test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_boilerplate(const IndexedStack(
|
||||
sizing: StackFit.expand,
|
||||
children: <Widget>[
|
||||
Text.rich(TextSpan(
|
||||
text: 'sub',
|
||||
children: <InlineSpan>[
|
||||
TextSpan(text: 'stringsub'),
|
||||
TextSpan(text: 'stringsub'),
|
||||
TextSpan(text: 'stringsub'),
|
||||
],
|
||||
)),
|
||||
Text('substringsub'),
|
||||
],
|
||||
)),
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('substringsub'), findsExactly(2)); // Pattern skips overlapping matches.
|
||||
expect(find.textRange.ofSubstring('substringsub').first.evaluate().single.textRange, const TextRange(start: 0, end: 12));
|
||||
expect(find.textRange.ofSubstring('substringsub').last.evaluate().single.textRange, const TextRange(start: 18, end: 30));
|
||||
|
||||
expect(
|
||||
find.textRange.ofSubstring('substringsub').first.evaluate().single.renderObject,
|
||||
find.textRange.ofSubstring('substringsub').last.evaluate().single.renderObject,
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('substringsub', skipOffstage: false), findsExactly(3));
|
||||
});
|
||||
|
||||
testWidgets('basic text span test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_boilerplate(const IndexedStack(
|
||||
sizing: StackFit.expand,
|
||||
children: <Widget>[
|
||||
Text.rich(TextSpan(
|
||||
text: 'sub',
|
||||
children: <InlineSpan>[
|
||||
TextSpan(text: 'stringsub'),
|
||||
TextSpan(text: 'stringsub'),
|
||||
TextSpan(text: 'stringsub'),
|
||||
],
|
||||
)),
|
||||
Text('substringsub'),
|
||||
],
|
||||
)),
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('substringsub'), findsExactly(2)); // Pattern skips overlapping matches.
|
||||
expect(find.textRange.ofSubstring('substringsub').first.evaluate().single.textRange, const TextRange(start: 0, end: 12));
|
||||
expect(find.textRange.ofSubstring('substringsub').last.evaluate().single.textRange, const TextRange(start: 18, end: 30));
|
||||
|
||||
expect(
|
||||
find.textRange.ofSubstring('substringsub').first.evaluate().single.renderObject,
|
||||
find.textRange.ofSubstring('substringsub').last.evaluate().single.renderObject,
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('substringsub', skipOffstage: false), findsExactly(3));
|
||||
});
|
||||
|
||||
testWidgets('descendentOf', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_boilerplate(
|
||||
const Column(
|
||||
children: <Widget>[
|
||||
Text.rich(TextSpan(text: 'text')),
|
||||
Text.rich(TextSpan(text: 'text')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('text'), findsExactly(2));
|
||||
expect(find.textRange.ofSubstring('text', descendentOf: find.text('text').first), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('finds only static text for now', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_boilerplate(
|
||||
EditableText(
|
||||
controller: TextEditingController(text: 'text'),
|
||||
focusNode: FocusNode(),
|
||||
style: const TextStyle(),
|
||||
cursorColor: const Color(0x00000000),
|
||||
backgroundCursorColor: const Color(0x00000000),
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textRange.ofSubstring('text'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('ChainedFinders chain properly', (WidgetTester tester) async {
|
||||
final GlobalKey key1 = GlobalKey();
|
||||
await tester.pumpWidget(
|
||||
|
Loading…
Reference in New Issue
Block a user