Fix gesture recognizer in selectable rich text should be focusable in… (#77730)
This commit is contained in:
parent
38fd5af5f1
commit
aaa7f8428a
@ -100,6 +100,42 @@ class InlineSpanSemanticsInformation {
|
|||||||
String toString() => '${objectRuntimeType(this, 'InlineSpanSemanticsInformation')}{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}';
|
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.
|
/// An immutable span of inline content which forms part of a paragraph.
|
||||||
///
|
///
|
||||||
/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s.
|
/// * 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
|
// 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 'dart:collection';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle;
|
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
|
@override
|
||||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||||
super.describeSemanticsConfiguration(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
|
config
|
||||||
..value = obscureText
|
..value = obscureText
|
||||||
? obscuringCharacter * _plainText.length
|
? 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
|
// TODO(ianh): in theory, [selection] could become null between when
|
||||||
// we last called describeSemanticsConfiguration and when the
|
// we last called describeSemanticsConfiguration and when the
|
||||||
// callbacks are invoked, in which case the callbacks will crash...
|
// callbacks are invoked, in which case the callbacks will crash...
|
||||||
|
@ -867,41 +867,6 @@ class RenderParagraph extends RenderBox
|
|||||||
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
|
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
|
||||||
List<InlineSpanSemanticsInformation>? _semanticsInfo;
|
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
|
@override
|
||||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||||
super.describeSemanticsConfiguration(config);
|
super.describeSemanticsConfiguration(config);
|
||||||
@ -938,7 +903,7 @@ class RenderParagraph extends RenderBox
|
|||||||
int childIndex = 0;
|
int childIndex = 0;
|
||||||
RenderBox? child = firstChild;
|
RenderBox? child = firstChild;
|
||||||
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
|
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
|
||||||
for (final InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
|
for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) {
|
||||||
final TextSelection selection = TextSelection(
|
final TextSelection selection = TextSelection(
|
||||||
baseOffset: start,
|
baseOffset: start,
|
||||||
extentOffset: start + info.text.length,
|
extentOffset: start + info.text.length,
|
||||||
@ -946,7 +911,7 @@ class RenderParagraph extends RenderBox
|
|||||||
start += info.text.length;
|
start += info.text.length;
|
||||||
|
|
||||||
if (info.isPlaceholder) {
|
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.
|
// to annotate all of the semantics nodes belong to this span.
|
||||||
while (children.length > childIndex &&
|
while (children.length > childIndex &&
|
||||||
children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
|
children.elementAt(childIndex).isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
|
||||||
|
@ -1444,6 +1444,57 @@ void main() {
|
|||||||
semantics.dispose();
|
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', () {
|
group('Keyboard Tests', () {
|
||||||
late TextEditingController controller;
|
late TextEditingController controller;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user