diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 7808926e91518..9f14bd85803e9 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -4,6 +4,8 @@ import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphStyle, TextBox; +import 'package:flutter/gestures.dart'; + import 'basic_types.dart'; import 'text_editing.dart'; import 'text_style.dart'; @@ -26,7 +28,8 @@ class TextSpan { const TextSpan({ this.style, this.text, - this.children + this.children, + this.recognizer }); /// The style to apply to the text and the children. @@ -44,6 +47,9 @@ class TextSpan { /// children. final List children; + /// If non-null, will receive events that hit this text span. + final GestureRecognizer recognizer; + void build(ui.ParagraphBuilder builder) { final bool hasStyle = style != null; if (hasStyle) @@ -60,13 +66,47 @@ class TextSpan { builder.pop(); } - void writePlainText(StringBuffer result) { - if (text != null) - result.write(text); + bool visitTextSpan(bool visitor(TextSpan span)) { + if (text != null) { + if (!visitor(this)) + return false; + } if (children != null) { - for (TextSpan child in children) - child.writePlainText(result); + for (TextSpan child in children) { + if (!child.visitTextSpan(visitor)) + return false; + } } + return true; + } + + TextSpan getSpanForPosition(TextPosition position) { + TextAffinity affinity = position.affinity; + int targetOffset = position.offset; + int offset = 0; + TextSpan result; + visitTextSpan((TextSpan span) { + assert(result == null); + int endOffset = offset + span.text.length; + if (targetOffset == offset && affinity == TextAffinity.downstream || + targetOffset > offset && targetOffset < endOffset || + targetOffset == endOffset && affinity == TextAffinity.upstream) { + result = span; + return false; + } + offset = endOffset; + return true; + }); + return result; + } + + String toPlainText() { + StringBuffer buffer = new StringBuffer(); + visitTextSpan((TextSpan span) { + buffer.write(span.text); + return true; + }); + return buffer.toString(); } String toString([String prefix = '']) { @@ -89,9 +129,10 @@ class TextSpan { final TextSpan typedOther = other; return typedOther.text == text && typedOther.style == style + && typedOther.recognizer == recognizer && _deepEquals(typedOther.children, children); } - int get hashCode => hashValues(style, text, hashList(children)); + int get hashCode => hashValues(style, text, recognizer, hashList(children)); } /// An object that paints a [TextSpan] into a canvas. diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index b622c3f4b1e35..f86a4ece29f74 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -2,6 +2,8 @@ // 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 'box.dart'; import 'object.dart'; import 'semantics.dart'; @@ -81,6 +83,16 @@ class RenderParagraph extends RenderBox { bool hitTestSelf(Point position) => true; + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (event is! PointerDownEvent) + return; + _layoutText(constraints); + Offset offset = entry.localPosition.toOffset(); + TextPosition position = _textPainter.getPositionForOffset(offset); + TextSpan span = _textPainter.text.getSpanForPosition(position); + span?.recognizer?.addPointer(event); + } + void performLayout() { _layoutText(constraints); size = constraints.constrain(_textPainter.size); @@ -100,9 +112,7 @@ class RenderParagraph extends RenderBox { Iterable getSemanticAnnotators() sync* { yield (SemanticsNode node) { - StringBuffer buffer = new StringBuffer(); - text.writePlainText(buffer); - node.label = buffer.toString(); + node.label = text.toPlainText(); }; } diff --git a/packages/flutter/test/widget/hyperlink_test.dart b/packages/flutter/test/widget/hyperlink_test.dart new file mode 100644 index 0000000000000..dd30068fca3ab --- /dev/null +++ b/packages/flutter/test/widget/hyperlink_test.dart @@ -0,0 +1,80 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:test/test.dart'; + +void main() { + test('Can tap a hyperlink', () { + testWidgets((WidgetTester tester) { + bool didTapLeft = false; + TapGestureRecognizer tapLeft = new TapGestureRecognizer( + router: Gesturer.instance.pointerRouter, + gestureArena: Gesturer.instance.gestureArena, + onTap: () { + didTapLeft = true; + } + ); + + bool didTapRight = false; + TapGestureRecognizer tapRight = new TapGestureRecognizer( + router: Gesturer.instance.pointerRouter, + gestureArena: Gesturer.instance.gestureArena, + onTap: () { + didTapRight = true; + } + ); + + Key textKey = new Key('text'); + + tester.pumpWidget( + new Center( + child: new RichText( + key: textKey, + text: new TextSpan( + children: [ + new TextSpan( + text: 'xxxxxxxx', + recognizer: tapLeft + ), + new TextSpan(text: 'yyyyyyyy'), + new TextSpan( + text: 'zzzzzzzzz', + recognizer: tapRight + ), + ] + ) + ) + ) + ); + + Element element = tester.findElementByKey(textKey); + RenderBox box = element.renderObject; + + expect(didTapLeft, isFalse); + expect(didTapRight, isFalse); + + tester.tapAt(box.localToGlobal(Point.origin) + new Offset(2.0, 2.0)); + + expect(didTapLeft, isTrue); + expect(didTapRight, isFalse); + + didTapLeft = false; + + tester.tapAt(box.localToGlobal(Point.origin) + new Offset(30.0, 2.0)); + + expect(didTapLeft, isTrue); + expect(didTapRight, isFalse); + + didTapLeft = false; + + tester.tapAt(box.localToGlobal(new Point(box.size.width, 0.0)) + new Offset(-2.0, 2.0)); + + expect(didTapLeft, isFalse); + expect(didTapRight, isTrue); + }); + }); +}