diff --git a/packages/flutter/lib/src/painting/basic_types.dart b/packages/flutter/lib/src/painting/basic_types.dart index f07db133cf..fe67c28feb 100644 --- a/packages/flutter/lib/src/painting/basic_types.dart +++ b/packages/flutter/lib/src/painting/basic_types.dart @@ -16,6 +16,7 @@ export 'dart:ui' show FontStyle, FontVariation, FontWeight, + GlyphInfo, ImageShader, Locale, MaskFilter, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index f48a4e5f9f..7b4768ad3e 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -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; diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 746478b6b1..b3206fa790 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -303,6 +303,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin? _cachedAttributedLabels; @@ -730,9 +731,18 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin 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; diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index eb22f15389..7f1613778f 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -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 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: [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 entry) => entry.target).whereType(), [textSpanA]); + // The last A. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + // Far away from the line. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // 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 entry) => entry.target).whereType(), [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 entry) => entry.target).whereType(), [textSpanBC]); + + // On C. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // After C. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // Not even remotely close. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + }); + + 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: [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 entry) => entry.target).whereType(), [textSpanA]); + + // Between A and B. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + + // On B. + expect(renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [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()); 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()); @@ -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()); 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()); 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 }); diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index 457e83de88..ad9e080232 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -796,6 +796,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: [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 entry) => entry.target).whereType(), [textSpanA]); + // The last A. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + // Far away from the line. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // 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 entry) => entry.target).whereType(), [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 entry) => entry.target).whereType(), [textSpanBC]); + + // On C. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanBC]); + + // After C. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + + // Not even remotely close. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(9999.0, 9999.0)), isFalse); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), []); + }); + + 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: [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 entry) => entry.target).whereType(), [textSpanA]); + + // Between A and B. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanA]); + + // On B. + expect(paragraph.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)), isTrue); + expect(result.path.map((HitTestEntry entry) => entry.target).whereType(), [textSpanB]); + }); + group('Selection', () { void selectionParagraph(RenderParagraph paragraph, TextPosition start, TextPosition end) { for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) { diff --git a/packages/flutter/test/widgets/slivers_padding_test.dart b/packages/flutter/test/widgets/slivers_padding_test.dart index 29cfe10692..391ebe6cbb 100644 --- a/packages/flutter/test/widgets/slivers_padding_test.dart +++ b/packages/flutter/test/widgets/slivers_padding_test.dart @@ -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 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()); 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()); 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()); 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()); 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()); 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()); 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()); 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()); 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()); - 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()); + expect((span as TextSpan).text, text); + case final HitTestTarget target: + fail('$target is not a TextSpan or a RenderParagraph.'); + } }