Unverified Commit 5647407f authored by Tong Mu's avatar Tong Mu Committed by GitHub

Add mouseCursor, onEnter, and onExit to TextSpan (#77754)

parent b44e536c
...@@ -28,6 +28,7 @@ void main() { ...@@ -28,6 +28,7 @@ void main() {
matchesGoldenFile('cupertino_navigation_demo.screen.1.png'), matchesGoldenFile('cupertino_navigation_demo.screen.1.png'),
); );
await tester.pump(); // Need a new frame after loading fonts to refresh layout.
// Tap some row to go to the next page. // Tap some row to go to the next page.
await tester.tap(find.text('Buy this cool color').first); await tester.tap(find.text('Buy this cool color').first);
await tester.pump(); await tester.pump();
......
...@@ -60,7 +60,7 @@ import 'text_style.dart'; ...@@ -60,7 +60,7 @@ import 'text_style.dart';
/// * [RichText], a widget for finer control of text rendering. /// * [RichText], a widget for finer control of text rendering.
/// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas]. /// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas].
@immutable @immutable
class TextSpan extends InlineSpan { class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotation {
/// Creates a [TextSpan] with the given values. /// Creates a [TextSpan] with the given values.
/// ///
/// For the object to be useful, at least one of [text] or /// For the object to be useful, at least one of [text] or
...@@ -70,8 +70,13 @@ class TextSpan extends InlineSpan { ...@@ -70,8 +70,13 @@ class TextSpan extends InlineSpan {
this.children, this.children,
TextStyle? style, TextStyle? style,
this.recognizer, this.recognizer,
MouseCursor? mouseCursor,
this.onEnter,
this.onExit,
this.semanticsLabel, this.semanticsLabel,
}) : assert(!(text == null && semanticsLabel != null)), }) : mouseCursor = mouseCursor ??
(recognizer == null ? MouseCursor.defer : SystemMouseCursors.click),
assert(!(text == null && semanticsLabel != null)),
super(style: style); super(style: style);
/// The text contained in this span. /// The text contained in this span.
...@@ -82,7 +87,6 @@ class TextSpan extends InlineSpan { ...@@ -82,7 +87,6 @@ class TextSpan extends InlineSpan {
/// This getter does not include the contents of its children. /// This getter does not include the contents of its children.
final String? text; final String? text;
/// Additional spans to include as children. /// Additional spans to include as children.
/// ///
/// If both [text] and [children] are non-null, the text will precede the /// If both [text] and [children] are non-null, the text will precede the
...@@ -115,7 +119,8 @@ class TextSpan extends InlineSpan { ...@@ -115,7 +119,8 @@ class TextSpan extends InlineSpan {
/// provided to an [InlineSpan] object. It defines a `BuzzingText` widget /// provided to an [InlineSpan] object. It defines a `BuzzingText` widget
/// which uses the [HapticFeedback] class to vibrate the device when the user /// which uses the [HapticFeedback] class to vibrate the device when the user
/// long-presses the "find the" span, which is underlined in wavy green. The /// long-presses the "find the" span, which is underlined in wavy green. The
/// hit-testing is handled by the [RichText] widget. /// hit-testing is handled by the [RichText] widget. It also changes the
/// hovering mouse cursor to `precise`.
/// ///
/// ```dart /// ```dart
/// class BuzzingText extends StatefulWidget { /// class BuzzingText extends StatefulWidget {
...@@ -160,6 +165,7 @@ class TextSpan extends InlineSpan { ...@@ -160,6 +165,7 @@ class TextSpan extends InlineSpan {
/// decorationStyle: TextDecorationStyle.wavy, /// decorationStyle: TextDecorationStyle.wavy,
/// ), /// ),
/// recognizer: _longPressRecognizer, /// recognizer: _longPressRecognizer,
/// mouseCursor: SystemMouseCursors.precise,
/// ), /// ),
/// const TextSpan( /// const TextSpan(
/// text: ' secret?', /// text: ' secret?',
...@@ -173,6 +179,32 @@ class TextSpan extends InlineSpan { ...@@ -173,6 +179,32 @@ class TextSpan extends InlineSpan {
/// {@end-tool} /// {@end-tool}
final GestureRecognizer? recognizer; final GestureRecognizer? recognizer;
/// Mouse cursor when the mouse hovers over this span.
///
/// The default value is [SystemMouseCursors.click] if [recognizer] is not
/// null, or [MouseCursor.defer] otherwise.
///
/// [TextSpan] itself does not implement hit testing or cursor changing.
/// The object that manages the [TextSpan] painting is responsible
/// to return the [TextSpan] in its hit test, as well as providing the
/// correct mouse cursor when the [TextSpan]'s mouse cursor is
/// [MouseCursor.defer].
final MouseCursor mouseCursor;
@override
final PointerEnterEventListener? onEnter;
@override
final PointerExitEventListener? onExit;
/// Returns the value of [mouseCursor].
///
/// This field, required by [MouseTrackerAnnotation], is hidden publicly to
/// avoid the confusion as a text cursor.
@protected
@override
MouseCursor get cursor => mouseCursor;
/// An alternative semantics label for this [TextSpan]. /// An alternative semantics label for this [TextSpan].
/// ///
/// If present, the semantics of this span will contain this value instead /// If present, the semantics of this span will contain this value instead
...@@ -186,6 +218,15 @@ class TextSpan extends InlineSpan { ...@@ -186,6 +218,15 @@ class TextSpan extends InlineSpan {
/// ``` /// ```
final String? semanticsLabel; final String? semanticsLabel;
@override
bool get validForMouseTracker => true;
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent)
recognizer?.addPointer(event);
}
/// Apply the [style], [text], and [children] of this object to the /// Apply the [style], [text], and [children] of this object to the
/// given [ParagraphBuilder], from which a [Paragraph] can be obtained. /// given [ParagraphBuilder], from which a [Paragraph] can be obtained.
/// [Paragraph] objects can be drawn on [Canvas] objects. /// [Paragraph] objects can be drawn on [Canvas] objects.
...@@ -405,6 +446,9 @@ class TextSpan extends InlineSpan { ...@@ -405,6 +446,9 @@ class TextSpan extends InlineSpan {
&& other.text == text && other.text == text
&& other.recognizer == recognizer && other.recognizer == recognizer
&& other.semanticsLabel == semanticsLabel && other.semanticsLabel == semanticsLabel
&& onEnter == other.onEnter
&& onExit == other.onExit
&& mouseCursor == other.mouseCursor
&& listEquals<InlineSpan>(other.children, children); && listEquals<InlineSpan>(other.children, children);
} }
...@@ -414,6 +458,9 @@ class TextSpan extends InlineSpan { ...@@ -414,6 +458,9 @@ class TextSpan extends InlineSpan {
text, text,
recognizer, recognizer,
semanticsLabel, semanticsLabel,
onEnter,
onExit,
mouseCursor,
hashList(children), hashList(children),
); );
...@@ -441,6 +488,15 @@ class TextSpan extends InlineSpan { ...@@ -441,6 +488,15 @@ class TextSpan extends InlineSpan {
defaultValue: null, defaultValue: null,
)); ));
properties.add(FlagsSummary<Function?>(
'callbacks',
<String, Function?> {
'enter': onEnter,
'exit': onExit,
},
));
properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor', cursor, defaultValue: MouseCursor.defer));
if (semanticsLabel != null) { if (semanticsLabel != null) {
properties.add(StringProperty('semanticsLabel', semanticsLabel)); properties.add(StringProperty('semanticsLabel', semanticsLabel));
} }
......
...@@ -2834,6 +2834,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ...@@ -2834,6 +2834,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
@override @override
bool hitTestSelf(Offset position) => true; bool hitTestSelf(Offset position) => true;
@override
@protected
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
final TextPosition textPosition = _textPainter.getPositionForOffset(position);
final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition);
if (span != null && span is HitTestTarget) {
result.add(HitTestEntry(span as HitTestTarget));
return true;
}
return false;
}
late TapGestureRecognizer _tap; late TapGestureRecognizer _tap;
late LongPressGestureRecognizer _longPress; late LongPressGestureRecognizer _longPress;
...@@ -2842,14 +2854,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ...@@ -2842,14 +2854,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(debugHandleEvent(event, entry)); assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) { if (event is PointerDownEvent) {
assert(!debugNeedsLayout); assert(!debugNeedsLayout);
// Checks if there is any gesture recognizer in the text span.
final Offset offset = entry.localPosition;
final TextPosition position = _textPainter.getPositionForOffset(offset);
final InlineSpan? span = _textPainter.text!.getSpanForPosition(position);
if (span != null && span is TextSpan) {
final TextSpan textSpan = span;
textSpan.recognizer?.addPointer(event);
}
if (!ignorePointer) { if (!ignorePointer) {
// Propagates the pointer event to selection handlers. // Propagates the pointer event to selection handlers.
......
...@@ -449,6 +449,18 @@ class RenderParagraph extends RenderBox ...@@ -449,6 +449,18 @@ class RenderParagraph extends RenderBox
@override @override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Hit test text spans.
late final bool hitText;
final TextPosition textPosition = _textPainter.getPositionForOffset(position);
final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition);
if (span != null && span is HitTestTarget) {
result.add(HitTestEntry(span as HitTestTarget));
hitText = true;
} else {
hitText = false;
}
// Hit test render object children
RenderBox? child = firstChild; RenderBox? child = firstChild;
int childIndex = 0; int childIndex = 0;
while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) { while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) {
...@@ -480,24 +492,7 @@ class RenderParagraph extends RenderBox ...@@ -480,24 +492,7 @@ class RenderParagraph extends RenderBox
child = childAfter(child); child = childAfter(child);
childIndex += 1; childIndex += 1;
} }
return false; return hitText;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent)
return;
_layoutTextWithConstraints(constraints);
final Offset offset = entry.localPosition;
final TextPosition position = _textPainter.getPositionForOffset(offset);
final InlineSpan? span = _textPainter.text!.getSpanForPosition(position);
if (span == null) {
return;
}
if (span is TextSpan) {
span.recognizer?.addPointer(event);
}
} }
bool _needsClipping = false; bool _needsClipping = false;
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// 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/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -25,6 +27,21 @@ void main() { ...@@ -25,6 +27,21 @@ void main() {
expect(a1 == c2, isFalse); expect(a1 == c2, isFalse);
expect(b1 == a2, isFalse); expect(b1 == a2, isFalse);
expect(c1 == b2, isFalse); expect(c1 == b2, isFalse);
void callback1(PointerEnterEvent _) {}
void callback2(PointerEnterEvent _) {}
final TextSpan d1 = TextSpan(text: 'a', onEnter: callback1);
final TextSpan d2 = TextSpan(text: 'a', onEnter: callback1);
final TextSpan d3 = TextSpan(text: 'a', onEnter: callback2);
final TextSpan e1 = TextSpan(text: 'a', onEnter: callback2, mouseCursor: SystemMouseCursors.forbidden);
final TextSpan e2 = TextSpan(text: 'a', onEnter: callback2, mouseCursor: SystemMouseCursors.forbidden);
expect(a1 == d1, isFalse);
expect(d1 == d2, isTrue);
expect(d2 == d3, isFalse);
expect(d3 == e1, isFalse);
expect(e1 == e2, isTrue);
}); });
test('TextSpan toStringDeep', () { test('TextSpan toStringDeep', () {
...@@ -59,6 +76,30 @@ void main() { ...@@ -59,6 +76,30 @@ void main() {
)); ));
}); });
test('TextSpan toStringDeep for mouse', () {
const TextSpan test1 = TextSpan(
text: 'a',
);
expect(test1.toStringDeep(), equals(
'TextSpan:\n'
' "a"\n'
));
final TextSpan test2 = TextSpan(
text: 'a',
onEnter: (_) {},
onExit: (_) {},
mouseCursor: SystemMouseCursors.forbidden,
);
expect(test2.toStringDeep(), equals(
'TextSpan:\n'
' "a"\n'
' callbacks: enter, exit\n'
' mouseCursor: SystemMouseCursor(forbidden)\n'
));
});
test('TextSpan toPlainText', () { test('TextSpan toPlainText', () {
const TextSpan textSpan = TextSpan( const TextSpan textSpan = TextSpan(
text: 'a', text: 'a',
...@@ -238,4 +279,89 @@ void main() { ...@@ -238,4 +279,89 @@ void main() {
expect(collector[0].text, 'aaa'); expect(collector[0].text, 'aaa');
expect(collector[0].semanticsLabel, 'bbb'); expect(collector[0].semanticsLabel, 'bbb');
}); });
testWidgets('handles mouse cursor', (WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Text.rich(
TextSpan(
text: 'xxxxx',
children: <InlineSpan>[
TextSpan(
text: 'yyyyy',
mouseCursor: SystemMouseCursors.forbidden,
),
TextSpan(
text: 'xxxxx',
),
],
),
textAlign: TextAlign.center,
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(RichText)) - const Offset(40, 0));
expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
await gesture.moveTo(tester.getCenter(find.byType(RichText)));
expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.forbidden);
await gesture.moveTo(tester.getCenter(find.byType(RichText)) + const Offset(40, 0));
expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('handles onEnter and onExit', (WidgetTester tester) async {
final List<PointerEvent> logEvents = <PointerEvent>[];
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Text.rich(
TextSpan(
text: 'xxxxx',
children: <InlineSpan>[
TextSpan(
text: 'yyyyy',
onEnter: (PointerEnterEvent event) {
logEvents.add(event);
},
onExit: (PointerExitEvent event) {
logEvents.add(event);
}
),
const TextSpan(
text: 'xxxxx',
),
],
),
textAlign: TextAlign.center,
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(RichText)) - const Offset(40, 0));
expect(logEvents, isEmpty);
await gesture.moveTo(tester.getCenter(find.byType(RichText)));
expect(logEvents.length, 1);
expect(logEvents[0], isA<PointerEnterEvent>());
await gesture.moveTo(tester.getCenter(find.byType(RichText)) + const Offset(40, 0));
expect(logEvents.length, 2);
expect(logEvents[1], isA<PointerExitEvent>());
});
} }
...@@ -178,15 +178,15 @@ void main() { ...@@ -178,15 +178,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));
expect(result.path.first.target, tester.firstRenderObject<RenderObject>(find.byType(Text))); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).skip(1).first); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).last); expectIsTextSpan(result.path.first.target, 'after');
}); });
testWidgets('Viewport+SliverPadding hit testing up', (WidgetTester tester) async { testWidgets('Viewport+SliverPadding hit testing up', (WidgetTester tester) async {
...@@ -200,15 +200,15 @@ void main() { ...@@ -200,15 +200,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));
expect(result.path.first.target, tester.firstRenderObject<RenderObject>(find.byType(Text))); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).skip(1).first); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).last); expectIsTextSpan(result.path.first.target, 'after');
}); });
testWidgets('Viewport+SliverPadding hit testing left', (WidgetTester tester) async { testWidgets('Viewport+SliverPadding hit testing left', (WidgetTester tester) async {
...@@ -222,15 +222,15 @@ void main() { ...@@ -222,15 +222,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));
expect(result.path.first.target, tester.firstRenderObject<RenderObject>(find.byType(Text))); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).skip(1).first); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).last); expectIsTextSpan(result.path.first.target, 'after');
}); });
testWidgets('Viewport+SliverPadding hit testing right', (WidgetTester tester) async { testWidgets('Viewport+SliverPadding hit testing right', (WidgetTester tester) async {
...@@ -244,15 +244,15 @@ void main() { ...@@ -244,15 +244,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));
expect(result.path.first.target, tester.firstRenderObject<RenderObject>(find.byType(Text))); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).skip(1).first); expectIsTextSpan(result.path.first.target, '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));
expect(result.path.first.target, tester.renderObjectList<RenderObject>(find.byType(Text)).last); expectIsTextSpan(result.path.first.target, 'after');
}); });
testWidgets('Viewport+SliverPadding no child', (WidgetTester tester) async { testWidgets('Viewport+SliverPadding no child', (WidgetTester tester) async {
...@@ -544,3 +544,8 @@ void main() { ...@@ -544,3 +544,8 @@ void main() {
expect(renderObject.geometry!.paintOrigin, 10.0); expect(renderObject.geometry!.paintOrigin, 10.0);
}); });
} }
void expectIsTextSpan(Object target, String text) {
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, text);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment