Unverified Commit aaa7f842 authored by chunhtai's avatar chunhtai Committed by GitHub

Fix gesture recognizer in selectable rich text should be focusable in… (#77730)

parent 38fd5af5
......@@ -100,6 +100,42 @@ class InlineSpanSemanticsInformation {
String toString() => '${objectRuntimeType(this, 'InlineSpanSemanticsInformation')}{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}';
}
/// Combines _semanticsInfo entries where permissible.
///
/// Consecutive inline spans can be combined if their
/// [InlineSpanSemanticsInformation.requiresOwnNode] return false.
List<InlineSpanSemanticsInformation> combineSemanticsInfo(List<InlineSpanSemanticsInformation> infoList) {
final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[];
String workingText = '';
// TODO(ianh): this algorithm is internally inconsistent. workingText
// never becomes null, but we check for it being so below.
String? workingLabel;
for (final InlineSpanSemanticsInformation info in infoList) {
if (info.requiresOwnNode) {
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel ?? workingText,
));
workingText = '';
workingLabel = null;
combined.add(info);
} else {
workingText += info.text;
workingLabel ??= '';
if (info.semanticsLabel != null) {
workingLabel += info.semanticsLabel!;
} else {
workingLabel += info.text;
}
}
}
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel,
));
return combined;
}
/// An immutable span of inline content which forms part of a paragraph.
///
/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s.
......
......@@ -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 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle;
......@@ -1474,10 +1475,34 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
}
/// Collected during [describeSemanticsConfiguration], used by
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
List<InlineSpanSemanticsInformation>? _semanticsInfo;
// Caches [SemanticsNode]s created during [assembleSemanticsNode] so they
// can be re-used when [assembleSemanticsNode] is called again. This ensures
// stable ids for the [SemanticsNode]s of [TextSpan]s across
// [assembleSemanticsNode] invocations.
Queue<SemanticsNode>? _cachedChildNodes;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
_semanticsInfo = _textPainter.text!.getSemanticsInformation();
// TODO(chunhtai): the macOS does not provide a public API to support text
// selections across multiple semantics nodes. Remove this platform check
// once we can support it.
// https://github.com/flutter/flutter/issues/77957
if (_semanticsInfo!.any((InlineSpanSemanticsInformation info) => info.recognizer != null) &&
defaultTargetPlatform != TargetPlatform.macOS) {
assert(readOnly && !obscureText);
// For Selectable rich text with recognizer, we need to create a semantics
// node for each text fragment.
config
..isSemanticBoundary = true
..explicitChildNodes = true;
return;
}
config
..value = obscureText
? obscuringCharacter * _plainText.length
......@@ -1520,6 +1545,87 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
);
}
@override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty);
final List<SemanticsNode> newChildren = <SemanticsNode>[];
TextDirection currentDirection = textDirection;
Rect currentRect;
double ordinal = 0.0;
int start = 0;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) {
assert(!info.isPlaceholder);
final TextSelection selection = TextSelection(
baseOffset: start,
extentOffset: start + info.text.length,
);
start += info.text.length;
final TextDirection initialDirection = currentDirection;
final List<ui.TextBox> rects = _textPainter.getBoxesForSelection(selection);
if (rects.isEmpty) {
continue;
}
Rect rect = rects.first.toRect();
currentDirection = rects.first.direction;
for (final ui.TextBox textBox in rects.skip(1)) {
rect = rect.expandToInclude(textBox.toRect());
currentDirection = textBox.direction;
}
// Any of the text boxes may have had infinite dimensions.
// We shouldn't pass infinite dimensions up to the bridges.
rect = Rect.fromLTWH(
math.max(0.0, rect.left),
math.max(0.0, rect.top),
math.min(rect.width, constraints.maxWidth),
math.min(rect.height, constraints.maxHeight),
);
// Round the current rectangle to make this API testable and add some
// padding so that the accessibility rects do not overlap with the text.
currentRect = Rect.fromLTRB(
rect.left.floorToDouble() - 4.0,
rect.top.floorToDouble() - 4.0,
rect.right.ceilToDouble() + 4.0,
rect.bottom.ceilToDouble() + 4.0,
);
final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection
..label = info.semanticsLabel ?? info.text;
final GestureRecognizer? recognizer = info.recognizer;
if (recognizer != null) {
if (recognizer is TapGestureRecognizer) {
if (recognizer.onTap != null) {
configuration.onTap = recognizer.onTap;
configuration.isLink = true;
}
} else if (recognizer is DoubleTapGestureRecognizer) {
if (recognizer.onDoubleTap != null) {
configuration.onTap = recognizer.onDoubleTap;
configuration.isLink = true;
}
} else if (recognizer is LongPressGestureRecognizer) {
if (recognizer.onLongPress != null) {
configuration.onLongPress = recognizer.onLongPress;
}
} else {
assert(false, '${recognizer.runtimeType} is not supported.');
}
}
final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true)
? _cachedChildNodes!.removeFirst()
: SemanticsNode();
newChild
..updateWith(config: configuration)
..rect = currentRect;
newChildCache.addLast(newChild);
newChildren.add(newChild);
}
_cachedChildNodes = newChildCache;
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
}
// TODO(ianh): in theory, [selection] could become null between when
// we last called describeSemanticsConfiguration and when the
// callbacks are invoked, in which case the callbacks will crash...
......
......@@ -867,41 +867,6 @@ class RenderParagraph extends RenderBox
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
List<InlineSpanSemanticsInformation>? _semanticsInfo;
/// Combines _semanticsInfo entries where permissible, determined by
/// [InlineSpanSemanticsInformation.requiresOwnNode].
List<InlineSpanSemanticsInformation> _combineSemanticsInfo() {
assert(_semanticsInfo != null);
final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[];
String workingText = '';
// TODO(ianh): this algorithm is internally inconsistent. workingText
// never becomes null, but we check for it being so below.
String? workingLabel;
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
if (info.requiresOwnNode) {
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel ?? workingText,
));
workingText = '';
workingLabel = null;
combined.add(info);
} else {
workingText += info.text;
workingLabel ??= '';
if (info.semanticsLabel != null) {
workingLabel += info.semanticsLabel!;
} else {
workingLabel += info.text;
}
}
}
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel,
));
return combined;
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
......@@ -938,7 +903,7 @@ class RenderParagraph extends RenderBox
int childIndex = 0;
RenderBox? child = firstChild;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
for (final InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) {
final TextSelection selection = TextSelection(
baseOffset: start,
extentOffset: start + info.text.length,
......@@ -946,7 +911,7 @@ class RenderParagraph extends RenderBox
start += info.text.length;
if (info.isPlaceholder) {
// A placeholder span may have 0 to multple semantics nodes, we need
// A placeholder span may have 0 to multiple semantics nodes, we need
// to annotate all of the semantics nodes belong to this span.
while (children.length > childIndex &&
children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
......
......@@ -1444,6 +1444,57 @@ void main() {
semantics.dispose();
});
testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
overlay(
child: SelectableText.rich(
TextSpan(
children: <TextSpan>[
const TextSpan(text: 'text'),
TextSpan(
text: 'link',
recognizer: TapGestureRecognizer()
..onTap = () { },
),
],
),
),
),
);
expect(semantics, hasSemantics(TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
id: 1,
actions: <SemanticsAction>[SemanticsAction.longPress],
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
label: 'text',
textDirection: TextDirection.ltr,
),
TestSemantics(
id: 4,
flags: <SemanticsFlag>[SemanticsFlag.isLink],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'link',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
), ignoreTransform: true, ignoreRect: true));
semantics.dispose();
});
group('Keyboard Tests', () {
late TextEditingController controller;
......
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