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() {
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.
await tester.tap(find.text('Buy this cool color').first);
await tester.pump();
......
......@@ -60,7 +60,7 @@ import 'text_style.dart';
/// * [RichText], a widget for finer control of text rendering.
/// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas].
@immutable
class TextSpan extends InlineSpan {
class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotation {
/// Creates a [TextSpan] with the given values.
///
/// For the object to be useful, at least one of [text] or
......@@ -70,8 +70,13 @@ class TextSpan extends InlineSpan {
this.children,
TextStyle? style,
this.recognizer,
MouseCursor? mouseCursor,
this.onEnter,
this.onExit,
this.semanticsLabel,
}) : assert(!(text == null && semanticsLabel != null)),
}) : mouseCursor = mouseCursor ??
(recognizer == null ? MouseCursor.defer : SystemMouseCursors.click),
assert(!(text == null && semanticsLabel != null)),
super(style: style);
/// The text contained in this span.
......@@ -82,7 +87,6 @@ class TextSpan extends InlineSpan {
/// This getter does not include the contents of its children.
final String? text;
/// Additional spans to include as children.
///
/// If both [text] and [children] are non-null, the text will precede the
......@@ -115,7 +119,8 @@ class TextSpan extends InlineSpan {
/// provided to an [InlineSpan] object. It defines a `BuzzingText` widget
/// 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
/// 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
/// class BuzzingText extends StatefulWidget {
......@@ -160,6 +165,7 @@ class TextSpan extends InlineSpan {
/// decorationStyle: TextDecorationStyle.wavy,
/// ),
/// recognizer: _longPressRecognizer,
/// mouseCursor: SystemMouseCursors.precise,
/// ),
/// const TextSpan(
/// text: ' secret?',
......@@ -173,6 +179,32 @@ class TextSpan extends InlineSpan {
/// {@end-tool}
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].
///
/// If present, the semantics of this span will contain this value instead
......@@ -186,6 +218,15 @@ class TextSpan extends InlineSpan {
/// ```
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
/// given [ParagraphBuilder], from which a [Paragraph] can be obtained.
/// [Paragraph] objects can be drawn on [Canvas] objects.
......@@ -405,6 +446,9 @@ class TextSpan extends InlineSpan {
&& other.text == text
&& other.recognizer == recognizer
&& other.semanticsLabel == semanticsLabel
&& onEnter == other.onEnter
&& onExit == other.onExit
&& mouseCursor == other.mouseCursor
&& listEquals<InlineSpan>(other.children, children);
}
......@@ -414,6 +458,9 @@ class TextSpan extends InlineSpan {
text,
recognizer,
semanticsLabel,
onEnter,
onExit,
mouseCursor,
hashList(children),
);
......@@ -441,6 +488,15 @@ class TextSpan extends InlineSpan {
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) {
properties.add(StringProperty('semanticsLabel', semanticsLabel));
}
......
......@@ -2834,6 +2834,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
@override
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 LongPressGestureRecognizer _longPress;
......@@ -2842,14 +2854,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
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) {
// Propagates the pointer event to selection handlers.
......
......@@ -449,6 +449,18 @@ class RenderParagraph extends RenderBox
@override
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;
int childIndex = 0;
while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) {
......@@ -480,24 +492,7 @@ class RenderParagraph extends RenderBox
child = childAfter(child);
childIndex += 1;
}
return false;
}
@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);
}
return hitText;
}
bool _needsClipping = false;
......
......@@ -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 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -25,6 +27,21 @@ void main() {
expect(a1 == c2, isFalse);
expect(b1 == a2, 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', () {
......@@ -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', () {
const TextSpan textSpan = TextSpan(
text: 'a',
......@@ -238,4 +279,89 @@ void main() {
expect(collector[0].text, 'aaa');
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() {
]);
HitTestResult result;
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));
expect(result.path.first.target, isA<RenderView>());
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));
expect(result.path.first.target, isA<RenderView>());
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 {
......@@ -200,15 +200,15 @@ void main() {
]);
HitTestResult result;
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));
expect(result.path.first.target, isA<RenderView>());
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));
expect(result.path.first.target, isA<RenderView>());
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 {
......@@ -222,15 +222,15 @@ void main() {
]);
HitTestResult result;
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));
expect(result.path.first.target, isA<RenderView>());
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));
expect(result.path.first.target, isA<RenderView>());
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 {
......@@ -244,15 +244,15 @@ void main() {
]);
HitTestResult result;
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));
expect(result.path.first.target, isA<RenderView>());
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));
expect(result.path.first.target, isA<RenderView>());
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 {
......@@ -544,3 +544,8 @@ void main() {
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