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,
|
FontStyle,
|
||||||
FontVariation,
|
FontVariation,
|
||||||
FontWeight,
|
FontWeight,
|
||||||
|
GlyphInfo,
|
||||||
ImageShader,
|
ImageShader,
|
||||||
Locale,
|
Locale,
|
||||||
MaskFilter,
|
MaskFilter,
|
||||||
|
@ -6,6 +6,7 @@ import 'dart:math' show max, min;
|
|||||||
import 'dart:ui' as ui show
|
import 'dart:ui' as ui show
|
||||||
BoxHeightStyle,
|
BoxHeightStyle,
|
||||||
BoxWidthStyle,
|
BoxWidthStyle,
|
||||||
|
GlyphInfo,
|
||||||
LineMetrics,
|
LineMetrics,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
ParagraphBuilder,
|
ParagraphBuilder,
|
||||||
@ -24,6 +25,7 @@ import 'strut_style.dart';
|
|||||||
import 'text_scaler.dart';
|
import 'text_scaler.dart';
|
||||||
import 'text_span.dart';
|
import 'text_span.dart';
|
||||||
|
|
||||||
|
export 'dart:ui' show LineMetrics;
|
||||||
export 'package:flutter/services.dart' show TextRange, TextSelection;
|
export 'package:flutter/services.dart' show TextRange, TextSelection;
|
||||||
|
|
||||||
/// The default font size if none is specified.
|
/// The default font size if none is specified.
|
||||||
@ -1493,7 +1495,24 @@ class TextPainter {
|
|||||||
: boxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false);
|
: 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) {
|
TextPosition getPositionForOffset(Offset offset) {
|
||||||
assert(_debugAssertTextLayoutIsValid);
|
assert(_debugAssertTextLayoutIsValid);
|
||||||
assert(!_debugNeedsRelayout);
|
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.
|
/// Returns the text span that contains the given position in the text.
|
||||||
@override
|
@override
|
||||||
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
|
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
|
||||||
if (text == null) {
|
final String? text = this.text;
|
||||||
|
if (text == null || text.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final TextAffinity affinity = position.affinity;
|
final TextAffinity affinity = position.affinity;
|
||||||
final int targetOffset = position.offset;
|
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 ||
|
if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
|
||||||
offset.value < targetOffset && targetOffset < endOffset ||
|
offset.value < targetOffset && targetOffset < endOffset ||
|
||||||
endOffset == targetOffset && affinity == TextAffinity.upstream) {
|
endOffset == targetOffset && affinity == TextAffinity.upstream) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
offset.increment(text!.length);
|
offset.increment(text.length);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1941,8 +1941,16 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
|
|||||||
@protected
|
@protected
|
||||||
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
||||||
final Offset effectivePosition = position - _paintOffset;
|
final Offset effectivePosition = position - _paintOffset;
|
||||||
final InlineSpan? textSpan = _textPainter.text;
|
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(effectivePosition);
|
||||||
switch (textSpan?.getSpanForPosition(_textPainter.getPositionForOffset(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:
|
case final HitTestTarget span:
|
||||||
result.add(HitTestEntry(span));
|
result.add(HitTestEntry(span));
|
||||||
return true;
|
return true;
|
||||||
|
@ -303,6 +303,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit);
|
static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit);
|
||||||
|
|
||||||
final TextPainter _textPainter;
|
final TextPainter _textPainter;
|
||||||
|
|
||||||
List<AttributedString>? _cachedAttributedLabels;
|
List<AttributedString>? _cachedAttributedLabels;
|
||||||
@ -730,9 +731,18 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
|||||||
bool hitTestSelf(Offset position) => true;
|
bool hitTestSelf(Offset position) => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@protected
|
||||||
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
|
||||||
final TextPosition textPosition = _textPainter.getPositionForOffset(position);
|
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position);
|
||||||
switch (_textPainter.text!.getSpanForPosition(textPosition)) {
|
// 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:
|
case final HitTestTarget span:
|
||||||
result.add(HitTestEntry(span));
|
result.add(HitTestEntry(span));
|
||||||
return true;
|
return true;
|
||||||
|
@ -250,6 +250,24 @@ void main() {
|
|||||||
expect(textSpan2.compareTo(textSpan2), RenderComparison.identical);
|
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', () {
|
test('GetSpanForPosition with WidgetSpan', () {
|
||||||
const TextSpan textSpan = TextSpan(
|
const TextSpan textSpan = TextSpan(
|
||||||
text: 'a',
|
text: 'a',
|
||||||
|
@ -12,6 +12,10 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'rendering_tester.dart';
|
import 'rendering_tester.dart';
|
||||||
|
|
||||||
|
double _caretMarginOf(RenderEditable renderEditable) {
|
||||||
|
return renderEditable.cursorWidth + 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
|
void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
|
||||||
int index = 0;
|
int index = 0;
|
||||||
RenderBox? previousBox;
|
RenderBox? previousBox;
|
||||||
@ -1184,8 +1188,107 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('hit testing', () {
|
group('hit testing', () {
|
||||||
test('hits correct TextSpan when not scrolled', () {
|
|
||||||
final TextSelectionDelegate delegate = _FakeEditableTextState();
|
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 RenderEditable editable = RenderEditable(
|
final RenderEditable editable = RenderEditable(
|
||||||
text: const TextSpan(
|
text: const TextSpan(
|
||||||
style: TextStyle(height: 1.0, fontSize: 10.0),
|
style: TextStyle(height: 1.0, fontSize: 10.0),
|
||||||
@ -1692,7 +1795,8 @@ void main() {
|
|||||||
// Prepare for painting after layout.
|
// Prepare for painting after layout.
|
||||||
pumpFrame(phase: EnginePhase.compositingBits);
|
pumpFrame(phase: EnginePhase.compositingBits);
|
||||||
BoxHitTestResult result = BoxHitTestResult();
|
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
|
// We expect two hit test entries in the path because the RenderEditable
|
||||||
// will add itself as well.
|
// will add itself as well.
|
||||||
expect(result.path, hasLength(2));
|
expect(result.path, hasLength(2));
|
||||||
@ -1702,7 +1806,7 @@ void main() {
|
|||||||
// Only testing the RenderEditable entry here once, not anymore below.
|
// Only testing the RenderEditable entry here once, not anymore below.
|
||||||
expect(result.path.last.target, isA<RenderEditable>());
|
expect(result.path.last.target, isA<RenderEditable>());
|
||||||
result = BoxHitTestResult();
|
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));
|
expect(result.path, hasLength(2));
|
||||||
target = result.path.first.target;
|
target = result.path.first.target;
|
||||||
expect(target, isA<TextSpan>());
|
expect(target, isA<TextSpan>());
|
||||||
@ -1775,7 +1879,8 @@ void main() {
|
|||||||
// Prepare for painting after layout.
|
// Prepare for painting after layout.
|
||||||
pumpFrame(phase: EnginePhase.compositingBits);
|
pumpFrame(phase: EnginePhase.compositingBits);
|
||||||
BoxHitTestResult result = BoxHitTestResult();
|
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
|
// We expect two hit test entries in the path because the RenderEditable
|
||||||
// will add itself as well.
|
// will add itself as well.
|
||||||
expect(result.path, hasLength(2));
|
expect(result.path, hasLength(2));
|
||||||
@ -1785,13 +1890,14 @@ void main() {
|
|||||||
// Only testing the RenderEditable entry here once, not anymore below.
|
// Only testing the RenderEditable entry here once, not anymore below.
|
||||||
expect(result.path.last.target, isA<RenderEditable>());
|
expect(result.path.last.target, isA<RenderEditable>());
|
||||||
result = BoxHitTestResult();
|
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));
|
expect(result.path, hasLength(2));
|
||||||
target = result.path.first.target;
|
target = result.path.first.target;
|
||||||
expect(target, isA<TextSpan>());
|
expect(target, isA<TextSpan>());
|
||||||
expect((target as TextSpan).text, text);
|
expect((target as TextSpan).text, text);
|
||||||
|
|
||||||
result = BoxHitTestResult();
|
result = BoxHitTestResult();
|
||||||
|
// "test" is 40 pixel wide.
|
||||||
editable.hitTest(result, position: const Offset(41.0, 0.0));
|
editable.hitTest(result, position: const Offset(41.0, 0.0));
|
||||||
expect(result.path, hasLength(3));
|
expect(result.path, hasLength(3));
|
||||||
target = result.path.first.target;
|
target = result.path.first.target;
|
||||||
@ -1814,7 +1920,7 @@ void main() {
|
|||||||
|
|
||||||
result = BoxHitTestResult();
|
result = BoxHitTestResult();
|
||||||
editable.hitTest(result, position: const Offset(5.0, 15.0));
|
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
|
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -761,6 +761,84 @@ void main() {
|
|||||||
expect(node.childrenCount, 2);
|
expect(node.childrenCount, 2);
|
||||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61020
|
}, 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', () {
|
group('Selection', () {
|
||||||
void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) {
|
void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) {
|
||||||
for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) {
|
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
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@ -15,7 +16,6 @@ class _MockRenderSliver extends RenderSliver {
|
|||||||
maxPaintExtent: 10,
|
maxPaintExtent: 10,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> test(WidgetTester tester, double offset, EdgeInsetsGeometry padding, AxisDirection axisDirection, TextDirection textDirection) {
|
Future<void> test(WidgetTester tester, double offset, EdgeInsetsGeometry padding, AxisDirection axisDirection, TextDirection textDirection) {
|
||||||
@ -180,15 +180,15 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
HitTestResult result;
|
HitTestResult result;
|
||||||
result = tester.hitTestOnBinding(const Offset(10.0, 10.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(10.0, 60.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(100.0, 100.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(100.0, 490.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(10.0, 520.0));
|
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 {
|
testWidgets('Viewport+SliverPadding hit testing up', (WidgetTester tester) async {
|
||||||
@ -202,15 +202,15 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
HitTestResult result;
|
HitTestResult result;
|
||||||
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-10.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-60.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(100.0, 600.0-100.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(100.0, 600.0-490.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(10.0, 600.0-520.0));
|
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 {
|
testWidgets('Viewport+SliverPadding hit testing left', (WidgetTester tester) async {
|
||||||
@ -224,15 +224,15 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
HitTestResult result;
|
HitTestResult result;
|
||||||
result = tester.hitTestOnBinding(const Offset(800.0-10.0, 10.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(800.0-60.0, 10.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(800.0-100.0, 100.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(800.0-490.0, 100.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(800.0-520.0, 10.0));
|
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 {
|
testWidgets('Viewport+SliverPadding hit testing right', (WidgetTester tester) async {
|
||||||
@ -246,15 +246,15 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
HitTestResult result;
|
HitTestResult result;
|
||||||
result = tester.hitTestOnBinding(const Offset(10.0, 10.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(60.0, 10.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(100.0, 100.0));
|
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));
|
result = tester.hitTestOnBinding(const Offset(490.0, 100.0));
|
||||||
expect(result.path.first.target, isA<RenderView>());
|
expect(result.path.first.target, isA<RenderView>());
|
||||||
result = tester.hitTestOnBinding(const Offset(520.0, 10.0));
|
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 {
|
testWidgets('Viewport+SliverPadding no child', (WidgetTester tester) async {
|
||||||
@ -617,7 +617,15 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void expectIsTextSpan(Object target, String text) {
|
void hitsText(HitTestResult hitTestResult, String text) {
|
||||||
expect(target, isA<TextSpan>());
|
switch (hitTestResult.path.first.target) {
|
||||||
expect((target as TextSpan).text, text);
|
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';
|
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:
|
// Examples can assume:
|
||||||
// typedef MyWidget = Placeholder;
|
// typedef MyWidget = Placeholder;
|
||||||
|
|
||||||
@ -997,6 +1014,47 @@ abstract class WidgetController {
|
|||||||
return tapAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), pointer: pointer, buttons: buttons, kind: kind);
|
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.
|
/// Dispatch a pointer down / pointer up sequence at the given location.
|
||||||
Future<void> tapAt(
|
Future<void> tapAt(
|
||||||
Offset location, {
|
Offset location, {
|
||||||
@ -1762,6 +1820,45 @@ abstract class WidgetController {
|
|||||||
/// in the documentation for the [flutter_test] library.
|
/// in the documentation for the [flutter_test] library.
|
||||||
static bool hitTestWarningShouldBeFatal = false;
|
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 }) {
|
Offset _getElementPoint(finders.FinderBase<Element> finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) {
|
||||||
TestAsyncUtils.guardSync();
|
TestAsyncUtils.guardSync();
|
||||||
final Iterable<Element> elements = finder.evaluate();
|
final Iterable<Element> elements = finder.evaluate();
|
||||||
@ -1791,17 +1888,10 @@ abstract class WidgetController {
|
|||||||
final FlutterView view = _viewOf(finder);
|
final FlutterView view = _viewOf(finder);
|
||||||
final HitTestResult result = HitTestResult();
|
final HitTestResult result = HitTestResult();
|
||||||
binding.hitTestInView(result, location, view.viewId);
|
binding.hitTestInView(result, location, view.viewId);
|
||||||
bool found = false;
|
final bool found = result.path.any((HitTestEntry entry) => entry.target == box);
|
||||||
for (final HitTestEntry entry in result.path) {
|
|
||||||
if (entry.target == box) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view);
|
final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view);
|
||||||
bool outOfBounds = false;
|
final bool outOfBounds = !(Offset.zero & renderView.size).contains(location);
|
||||||
outOfBounds = !(Offset.zero & renderView.size).contains(location);
|
|
||||||
if (hitTestWarningShouldBeFatal) {
|
if (hitTestWarningShouldBeFatal) {
|
||||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||||
ErrorSummary('Finder specifies a widget that would not receive pointer events.'),
|
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].
|
/// Signature for [FinderBase.describeMatch].
|
||||||
typedef DescribeMatchCallback = String Function(Plurality plurality);
|
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.
|
/// Some frequently used [Finder]s and [SemanticsFinder]s.
|
||||||
const CommonFinders find = CommonFinders._();
|
const CommonFinders find = CommonFinders._();
|
||||||
|
|
||||||
@ -42,6 +62,9 @@ class CommonFinders {
|
|||||||
/// Some frequently used semantics finders.
|
/// Some frequently used semantics finders.
|
||||||
CommonSemanticsFinders get semantics => const CommonSemanticsFinders._();
|
CommonSemanticsFinders get semantics => const CommonSemanticsFinders._();
|
||||||
|
|
||||||
|
/// Some frequently used text range finders.
|
||||||
|
CommonTextRangeFinders get textRange => const CommonTextRangeFinders._();
|
||||||
|
|
||||||
/// Finds [Text], [EditableText], and optionally [RichText] widgets
|
/// Finds [Text], [EditableText], and optionally [RichText] widgets
|
||||||
/// containing string equal to the `text` argument.
|
/// 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.
|
/// Describes how a string of text should be pluralized.
|
||||||
enum Plurality {
|
enum Plurality {
|
||||||
/// Text should be pluralized to describe zero items.
|
/// Text should be pluralized to describe zero items.
|
||||||
@ -998,7 +1050,7 @@ abstract class Finder extends FinderBase<Element> with _LegacyFinderMixin {
|
|||||||
@override
|
@override
|
||||||
String describeMatch(Plurality plurality) {
|
String describeMatch(Plurality plurality) {
|
||||||
return switch (plurality) {
|
return switch (plurality) {
|
||||||
Plurality.zero ||Plurality.many => 'widgets with $description',
|
Plurality.zero || Plurality.many => 'widgets with $description',
|
||||||
Plurality.one => 'widget 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].
|
/// A mixin that applies additional filtering to the results of a parent [Finder].
|
||||||
mixin ChainedFinderMixin<CandidateType> on FinderBase<CandidateType> {
|
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 {
|
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 {
|
testWidgets('ChainedFinders chain properly', (WidgetTester tester) async {
|
||||||
final GlobalKey key1 = GlobalKey();
|
final GlobalKey key1 = GlobalKey();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user