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}';
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user