Allow semantics labels to be shorter or longer than raw text (#36243)
This commit is contained in:
parent
9b08effaab
commit
c96806ec88
@ -35,6 +35,68 @@ class Accumulator {
|
||||
/// [InlineSpan]s.
|
||||
typedef InlineSpanVisitor = bool Function(InlineSpan span);
|
||||
|
||||
/// The textual and semantic label information for an [InlineSpan].
|
||||
///
|
||||
/// For [PlaceholderSpan]s, [InlineSpanSemanticsInformation.placeholder] is used by default.
|
||||
///
|
||||
/// See also:
|
||||
/// * [InlineSpan.getSemanticsInformation]
|
||||
@immutable
|
||||
class InlineSpanSemanticsInformation {
|
||||
/// Constructs an object that holds the text and sematnics label values of an
|
||||
/// [InlineSpan].
|
||||
///
|
||||
/// The text parameter must not be null.
|
||||
///
|
||||
/// Use [InlineSpanSemanticsInformation.placeholder] instead of directly setting
|
||||
/// [isPlaceholder].
|
||||
const InlineSpanSemanticsInformation(
|
||||
this.text, {
|
||||
this.isPlaceholder = false,
|
||||
this.semanticsLabel,
|
||||
this.recognizer
|
||||
}) : assert(text != null),
|
||||
assert(isPlaceholder != null),
|
||||
assert(isPlaceholder == false || (text == '\uFFFC' && semanticsLabel == null && recognizer == null)),
|
||||
requiresOwnNode = isPlaceholder || recognizer != null;
|
||||
|
||||
/// The text info for a [PlaceholderSpan].
|
||||
static const InlineSpanSemanticsInformation placeholder = InlineSpanSemanticsInformation('\uFFFC', isPlaceholder: true);
|
||||
|
||||
/// The text value, if any. For [PlaceholderSpan]s, this will be the unicode
|
||||
/// placeholder value.
|
||||
final String text;
|
||||
|
||||
/// The semanticsLabel, if any.
|
||||
final String semanticsLabel;
|
||||
|
||||
/// The gesture recognizer, if any, for this span.
|
||||
final GestureRecognizer recognizer;
|
||||
|
||||
/// Whether this is for a placeholder span.
|
||||
final bool isPlaceholder;
|
||||
|
||||
/// True if this configuration should get its own semantics node.
|
||||
///
|
||||
/// This will be the case of the [recognizer] is not null, of if
|
||||
/// [isPlaceholder] is true.
|
||||
final bool requiresOwnNode;
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
if (other is! InlineSpanSemanticsInformation) {
|
||||
return false;
|
||||
}
|
||||
return other.text == text && other.semanticsLabel == semanticsLabel && other.recognizer == recognizer && other.isPlaceholder == isPlaceholder;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(text, semanticsLabel, recognizer, isPlaceholder);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}';
|
||||
}
|
||||
|
||||
/// An immutable span of inline content which forms part of a paragraph.
|
||||
///
|
||||
/// * The subclass [TextSpan] specifies text and may contain child [InlineSpan]s.
|
||||
@ -175,6 +237,28 @@ abstract class InlineSpan extends DiagnosticableTree {
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Flattens the [InlineSpan] tree to a list of
|
||||
/// [InlineSpanSemanticsInformation] objects.
|
||||
///
|
||||
/// [PlaceholderSpan]s in the tree will be represented with a
|
||||
/// [InlineSpanSemanticsInformation.placeholder] value.
|
||||
List<InlineSpanSemanticsInformation> getSemanticsInformation() {
|
||||
final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
|
||||
computeSemanticsInformation(collector);
|
||||
return collector;
|
||||
}
|
||||
|
||||
/// Walks the [InlineSpan] tree and accumulates a list of
|
||||
/// [InlineSpanSemanticsInformation] objects.
|
||||
///
|
||||
/// This method should not be directly called. Use
|
||||
/// [getSemanticsInformation] instead.
|
||||
///
|
||||
/// [PlaceholderSpan]s in the tree will be represented with a
|
||||
/// [InlineSpanSemanticsInformation.placeholder] value.
|
||||
@protected
|
||||
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector);
|
||||
|
||||
/// Walks the [InlineSpan] tree and writes the plain text representation to `buffer`.
|
||||
///
|
||||
/// This method should not be directly called. Use [toPlainText] instead.
|
||||
@ -229,6 +313,7 @@ abstract class InlineSpan extends DiagnosticableTree {
|
||||
///
|
||||
/// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to
|
||||
/// `semanticsElements` for [PlaceholderSpan]s.
|
||||
@Deprecated('Implement computeSemanticsInformation instead.')
|
||||
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements);
|
||||
|
||||
/// In checked mode, throws an exception if the object is not in a
|
||||
|
@ -60,6 +60,11 @@ abstract class PlaceholderSpan extends InlineSpan {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
|
||||
collector.add(InlineSpanSemanticsInformation.placeholder);
|
||||
}
|
||||
|
||||
// TODO(garyq): Remove this after next stable release.
|
||||
/// The [visitTextSpan] method is invalid on [PlaceholderSpan]s
|
||||
@override
|
||||
|
@ -291,6 +291,23 @@ class TextSpan extends InlineSpan {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
|
||||
assert(debugAssertIsValid());
|
||||
if (text != null || semanticsLabel != null) {
|
||||
collector.add(InlineSpanSemanticsInformation(
|
||||
text,
|
||||
semanticsLabel: semanticsLabel,
|
||||
recognizer: recognizer,
|
||||
));
|
||||
}
|
||||
if (children != null) {
|
||||
for (InlineSpan child in children) {
|
||||
child.computeSemanticsInformation(collector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int codeUnitAtVisitor(int index, Accumulator offset) {
|
||||
if (text == null) {
|
||||
|
@ -731,49 +731,83 @@ class RenderParagraph extends RenderBox
|
||||
return _textPainter.size;
|
||||
}
|
||||
|
||||
// The offsets for each span that requires custom semantics.
|
||||
final List<int> _inlineSemanticsOffsets = <int>[];
|
||||
// Holds either [GestureRecognizer] or null (for placeholders) to generate
|
||||
// proper semnatics configurations.
|
||||
final List<dynamic> _inlineSemanticsElements = <dynamic>[];
|
||||
/// Collected during [describeSemanticsConfiguration], used by
|
||||
/// [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 = '';
|
||||
String workingLabel;
|
||||
for (InlineSpanSemanticsInformation info in _semanticsInfo) {
|
||||
if (info.requiresOwnNode) {
|
||||
if (workingText != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (workingText != null) {
|
||||
combined.add(InlineSpanSemanticsInformation(
|
||||
workingText,
|
||||
semanticsLabel: workingLabel,
|
||||
));
|
||||
} else {
|
||||
assert(workingLabel != null);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
@override
|
||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||
super.describeSemanticsConfiguration(config);
|
||||
_inlineSemanticsOffsets.clear();
|
||||
_inlineSemanticsElements.clear();
|
||||
final Accumulator offset = Accumulator();
|
||||
text.visitChildren((InlineSpan span) {
|
||||
span.describeSemantics(offset, _inlineSemanticsOffsets, _inlineSemanticsElements);
|
||||
return true;
|
||||
});
|
||||
if (_inlineSemanticsOffsets.isNotEmpty) {
|
||||
_semanticsInfo = text.getSemanticsInformation();
|
||||
|
||||
if (_semanticsInfo.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) {
|
||||
config.explicitChildNodes = true;
|
||||
config.isSemanticBoundary = true;
|
||||
} else {
|
||||
config.label = text.toPlainText();
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
for (InlineSpanSemanticsInformation info in _semanticsInfo) {
|
||||
buffer.write(info.semanticsLabel ?? info.text);
|
||||
}
|
||||
config.label = buffer.toString();
|
||||
config.textDirection = textDirection;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
|
||||
assert(_inlineSemanticsOffsets.isNotEmpty);
|
||||
assert(_inlineSemanticsOffsets.length.isEven);
|
||||
assert(_inlineSemanticsElements.isNotEmpty);
|
||||
assert(_semanticsInfo != null && _semanticsInfo.isNotEmpty);
|
||||
final List<SemanticsNode> newChildren = <SemanticsNode>[];
|
||||
final String rawLabel = text.toPlainText();
|
||||
int current = 0;
|
||||
double order = -1.0;
|
||||
TextDirection currentDirection = textDirection;
|
||||
Rect currentRect;
|
||||
|
||||
SemanticsConfiguration buildSemanticsConfig(int start, int end) {
|
||||
double ordinal = 0.0;
|
||||
int start = 0;
|
||||
int placeholderIndex = 0;
|
||||
RenderBox child = firstChild;
|
||||
for (InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
|
||||
final TextDirection initialDirection = currentDirection;
|
||||
final TextSelection selection = TextSelection(baseOffset: start, extentOffset: end);
|
||||
final TextSelection selection = TextSelection(baseOffset: start, extentOffset: start + info.text.length);
|
||||
final List<ui.TextBox> rects = getBoxesForSelection(selection);
|
||||
if (rects.isEmpty) {
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
Rect rect = rects.first.toRect();
|
||||
currentDirection = rects.first.direction;
|
||||
@ -791,64 +825,15 @@ class RenderParagraph extends RenderBox
|
||||
);
|
||||
// round the current rectangle to make this API testable and add some
|
||||
// padding so that the accessibility rects do not overlap with the text.
|
||||
// TODO(jonahwilliams): implement this for all text accessibility rects.
|
||||
currentRect = Rect.fromLTRB(
|
||||
rect.left.floorToDouble() - 4.0,
|
||||
rect.top.floorToDouble() - 4.0,
|
||||
rect.right.ceilToDouble() + 4.0,
|
||||
rect.bottom.ceilToDouble() + 4.0,
|
||||
);
|
||||
order += 1;
|
||||
final SemanticsConfiguration configuration = SemanticsConfiguration()
|
||||
..sortKey = OrdinalSortKey(order)
|
||||
..textDirection = initialDirection
|
||||
..label = rawLabel.substring(start, end);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
int childIndex = 0;
|
||||
RenderBox child = firstChild;
|
||||
for (int i = 0, j = 0; i < _inlineSemanticsOffsets.length; i += 2, j++) {
|
||||
final int start = _inlineSemanticsOffsets[i];
|
||||
final int end = _inlineSemanticsOffsets[i + 1];
|
||||
// Add semantics for any text between the previous recognizer/widget and this one.
|
||||
if (current != start) {
|
||||
final SemanticsNode node = SemanticsNode();
|
||||
final SemanticsConfiguration configuration = buildSemanticsConfig(current, start);
|
||||
if (configuration == null) {
|
||||
continue;
|
||||
}
|
||||
node.updateWith(config: configuration);
|
||||
node.rect = currentRect;
|
||||
newChildren.add(node);
|
||||
}
|
||||
final dynamic inlineElement = _inlineSemanticsElements[j];
|
||||
final SemanticsConfiguration configuration = buildSemanticsConfig(start, end);
|
||||
if (configuration == null) {
|
||||
continue;
|
||||
}
|
||||
if (inlineElement != null) {
|
||||
// Add semantics for this recognizer.
|
||||
final SemanticsNode node = SemanticsNode();
|
||||
if (inlineElement is TapGestureRecognizer) {
|
||||
final TapGestureRecognizer recognizer = inlineElement;
|
||||
configuration.onTap = recognizer.onTap;
|
||||
} else if (inlineElement is LongPressGestureRecognizer) {
|
||||
final LongPressGestureRecognizer recognizer = inlineElement;
|
||||
configuration.onLongPress = recognizer.onLongPress;
|
||||
} else {
|
||||
assert(false);
|
||||
}
|
||||
node.updateWith(config: configuration);
|
||||
node.rect = currentRect;
|
||||
newChildren.add(node);
|
||||
} else if (childIndex < children.length) {
|
||||
// Add semantics for this placeholder. Semantics are precomputed in the children
|
||||
// argument.
|
||||
// Placeholders should not get a label, which would come through as an
|
||||
// object replacement character.
|
||||
configuration.label = '';
|
||||
final SemanticsNode childNode = children.elementAt(childIndex);
|
||||
if (info.isPlaceholder) {
|
||||
final SemanticsNode childNode = children.elementAt(placeholderIndex++);
|
||||
final TextParentData parentData = child.parentData;
|
||||
childNode.rect = Rect.fromLTWH(
|
||||
childNode.rect.left,
|
||||
@ -856,20 +841,31 @@ class RenderParagraph extends RenderBox
|
||||
childNode.rect.width * parentData.scale,
|
||||
childNode.rect.height * parentData.scale,
|
||||
);
|
||||
newChildren.add(children.elementAt(childIndex));
|
||||
childIndex += 1;
|
||||
newChildren.add(childNode);
|
||||
child = childAfter(child);
|
||||
} else {
|
||||
final SemanticsConfiguration configuration = SemanticsConfiguration()
|
||||
..sortKey = OrdinalSortKey(ordinal++)
|
||||
..textDirection = initialDirection
|
||||
..label = info.semanticsLabel ?? info.text;
|
||||
if (info.recognizer != null) {
|
||||
if (info.recognizer is TapGestureRecognizer) {
|
||||
final TapGestureRecognizer recognizer = info.recognizer;
|
||||
configuration.onTap = recognizer.onTap;
|
||||
} else if (info.recognizer is LongPressGestureRecognizer) {
|
||||
final LongPressGestureRecognizer recognizer = info.recognizer;
|
||||
configuration.onLongPress = recognizer.onLongPress;
|
||||
} else {
|
||||
assert(false);
|
||||
}
|
||||
current = end;
|
||||
}
|
||||
if (current < rawLabel.length) {
|
||||
final SemanticsNode node = SemanticsNode();
|
||||
final SemanticsConfiguration configuration = buildSemanticsConfig(current, rawLabel.length);
|
||||
if (configuration != null) {
|
||||
node.updateWith(config: configuration);
|
||||
node.rect = currentRect;
|
||||
newChildren.add(node);
|
||||
newChildren.add(
|
||||
SemanticsNode()
|
||||
..updateWith(config: configuration)
|
||||
..rect = currentRect,
|
||||
);
|
||||
}
|
||||
start += info.text.length;
|
||||
}
|
||||
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
|
||||
}
|
||||
|
@ -141,6 +141,44 @@ void main() {
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('semanticsLabel can be shorter than text', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: <InlineSpan>[
|
||||
const TextSpan(
|
||||
text: 'Some Text',
|
||||
semanticsLabel: '',
|
||||
),
|
||||
TextSpan(
|
||||
text: 'Clickable',
|
||||
recognizer: TapGestureRecognizer()..onTap = () { },
|
||||
),
|
||||
]),
|
||||
),
|
||||
));
|
||||
final TestSemantics expectedSemantics = TestSemantics.root(
|
||||
children: <TestSemantics>[
|
||||
TestSemantics(
|
||||
children: <TestSemantics>[
|
||||
TestSemantics(
|
||||
textDirection: TextDirection.ltr,
|
||||
),
|
||||
TestSemantics(
|
||||
label: 'Clickable',
|
||||
actions: <SemanticsAction>[SemanticsAction.tap],
|
||||
textDirection: TextDirection.ltr,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true));
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('recognizers split semantic node', (WidgetTester tester) async {
|
||||
final SemanticsTester semantics = SemanticsTester(tester);
|
||||
const TextStyle textStyle = TextStyle(fontFamily: 'Ahem');
|
||||
|
Loading…
x
Reference in New Issue
Block a user