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.
|
/// [InlineSpan]s.
|
||||||
typedef InlineSpanVisitor = bool Function(InlineSpan span);
|
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.
|
/// 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.
|
||||||
@ -175,6 +237,28 @@ abstract class InlineSpan extends DiagnosticableTree {
|
|||||||
return buffer.toString();
|
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`.
|
/// Walks the [InlineSpan] tree and writes the plain text representation to `buffer`.
|
||||||
///
|
///
|
||||||
/// This method should not be directly called. Use [toPlainText] instead.
|
/// 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
|
/// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to
|
||||||
/// `semanticsElements` for [PlaceholderSpan]s.
|
/// `semanticsElements` for [PlaceholderSpan]s.
|
||||||
|
@Deprecated('Implement computeSemanticsInformation instead.')
|
||||||
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements);
|
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements);
|
||||||
|
|
||||||
/// In checked mode, throws an exception if the object is not in a
|
/// 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.
|
// TODO(garyq): Remove this after next stable release.
|
||||||
/// The [visitTextSpan] method is invalid on [PlaceholderSpan]s
|
/// The [visitTextSpan] method is invalid on [PlaceholderSpan]s
|
||||||
@override
|
@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
|
@override
|
||||||
int codeUnitAtVisitor(int index, Accumulator offset) {
|
int codeUnitAtVisitor(int index, Accumulator offset) {
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
|
@ -731,49 +731,83 @@ class RenderParagraph extends RenderBox
|
|||||||
return _textPainter.size;
|
return _textPainter.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The offsets for each span that requires custom semantics.
|
/// Collected during [describeSemanticsConfiguration], used by
|
||||||
final List<int> _inlineSemanticsOffsets = <int>[];
|
/// [assembleSemanticsNode] and [_combineSemanticsInfo].
|
||||||
// Holds either [GestureRecognizer] or null (for placeholders) to generate
|
List<InlineSpanSemanticsInformation> _semanticsInfo;
|
||||||
// proper semnatics configurations.
|
|
||||||
final List<dynamic> _inlineSemanticsElements = <dynamic>[];
|
/// 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
|
@override
|
||||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||||
super.describeSemanticsConfiguration(config);
|
super.describeSemanticsConfiguration(config);
|
||||||
_inlineSemanticsOffsets.clear();
|
_semanticsInfo = text.getSemanticsInformation();
|
||||||
_inlineSemanticsElements.clear();
|
|
||||||
final Accumulator offset = Accumulator();
|
if (_semanticsInfo.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) {
|
||||||
text.visitChildren((InlineSpan span) {
|
|
||||||
span.describeSemantics(offset, _inlineSemanticsOffsets, _inlineSemanticsElements);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (_inlineSemanticsOffsets.isNotEmpty) {
|
|
||||||
config.explicitChildNodes = true;
|
config.explicitChildNodes = true;
|
||||||
config.isSemanticBoundary = true;
|
config.isSemanticBoundary = true;
|
||||||
} else {
|
} 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;
|
config.textDirection = textDirection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
|
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
|
||||||
assert(_inlineSemanticsOffsets.isNotEmpty);
|
assert(_semanticsInfo != null && _semanticsInfo.isNotEmpty);
|
||||||
assert(_inlineSemanticsOffsets.length.isEven);
|
|
||||||
assert(_inlineSemanticsElements.isNotEmpty);
|
|
||||||
final List<SemanticsNode> newChildren = <SemanticsNode>[];
|
final List<SemanticsNode> newChildren = <SemanticsNode>[];
|
||||||
final String rawLabel = text.toPlainText();
|
|
||||||
int current = 0;
|
|
||||||
double order = -1.0;
|
|
||||||
TextDirection currentDirection = textDirection;
|
TextDirection currentDirection = textDirection;
|
||||||
Rect currentRect;
|
Rect currentRect;
|
||||||
|
double ordinal = 0.0;
|
||||||
SemanticsConfiguration buildSemanticsConfig(int start, int end) {
|
int start = 0;
|
||||||
|
int placeholderIndex = 0;
|
||||||
|
RenderBox child = firstChild;
|
||||||
|
for (InlineSpanSemanticsInformation info in _combineSemanticsInfo()) {
|
||||||
final TextDirection initialDirection = currentDirection;
|
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);
|
final List<ui.TextBox> rects = getBoxesForSelection(selection);
|
||||||
if (rects.isEmpty) {
|
if (rects.isEmpty) {
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
Rect rect = rects.first.toRect();
|
Rect rect = rects.first.toRect();
|
||||||
currentDirection = rects.first.direction;
|
currentDirection = rects.first.direction;
|
||||||
@ -791,64 +825,15 @@ class RenderParagraph extends RenderBox
|
|||||||
);
|
);
|
||||||
// round the current rectangle to make this API testable and add some
|
// round the current rectangle to make this API testable and add some
|
||||||
// padding so that the accessibility rects do not overlap with the text.
|
// padding so that the accessibility rects do not overlap with the text.
|
||||||
// TODO(jonahwilliams): implement this for all text accessibility rects.
|
|
||||||
currentRect = Rect.fromLTRB(
|
currentRect = Rect.fromLTRB(
|
||||||
rect.left.floorToDouble() - 4.0,
|
rect.left.floorToDouble() - 4.0,
|
||||||
rect.top.floorToDouble() - 4.0,
|
rect.top.floorToDouble() - 4.0,
|
||||||
rect.right.ceilToDouble() + 4.0,
|
rect.right.ceilToDouble() + 4.0,
|
||||||
rect.bottom.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;
|
if (info.isPlaceholder) {
|
||||||
RenderBox child = firstChild;
|
final SemanticsNode childNode = children.elementAt(placeholderIndex++);
|
||||||
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);
|
|
||||||
final TextParentData parentData = child.parentData;
|
final TextParentData parentData = child.parentData;
|
||||||
childNode.rect = Rect.fromLTWH(
|
childNode.rect = Rect.fromLTWH(
|
||||||
childNode.rect.left,
|
childNode.rect.left,
|
||||||
@ -856,20 +841,31 @@ class RenderParagraph extends RenderBox
|
|||||||
childNode.rect.width * parentData.scale,
|
childNode.rect.width * parentData.scale,
|
||||||
childNode.rect.height * parentData.scale,
|
childNode.rect.height * parentData.scale,
|
||||||
);
|
);
|
||||||
newChildren.add(children.elementAt(childIndex));
|
newChildren.add(childNode);
|
||||||
childIndex += 1;
|
|
||||||
child = childAfter(child);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newChildren.add(
|
||||||
|
SemanticsNode()
|
||||||
|
..updateWith(config: configuration)
|
||||||
|
..rect = currentRect,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
current = end;
|
start += info.text.length;
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
|
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
|
||||||
}
|
}
|
||||||
|
@ -141,6 +141,44 @@ void main() {
|
|||||||
semantics.dispose();
|
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 {
|
testWidgets('recognizers split semantic node', (WidgetTester tester) async {
|
||||||
final SemanticsTester semantics = SemanticsTester(tester);
|
final SemanticsTester semantics = SemanticsTester(tester);
|
||||||
const TextStyle textStyle = TextStyle(fontFamily: 'Ahem');
|
const TextStyle textStyle = TextStyle(fontFamily: 'Ahem');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user