From 34ff00a75219773ac5288bacfe6b937ebd9db8ca Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Thu, 25 Jan 2018 10:12:02 -0800 Subject: [PATCH] Add a11y support for selected text (#14254) Framework side for https://github.com/flutter/engine/pull/4584 & https://github.com/flutter/engine/pull/4587. Also rolls engine to 4c82c566edf394a5cfc237a266aea5bd37a6c172. --- bin/internal/engine.version | 2 +- .../flutter/lib/src/rendering/editable.dart | 1 + .../flutter/lib/src/semantics/semantics.dart | 36 +++++++- .../test/material/text_field_test.dart | 83 +++++++++++++++++++ .../test/widgets/semantics_tester.dart | 10 +++ 5 files changed, 130 insertions(+), 2 deletions(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 1d937e44978..61f9bc0051e 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -93296fb4ea653a3064643266d89dddd97d062f4a +4c82c566edf394a5cfc237a266aea5bd37a6c172 diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 3a4a36c9d0d..63b95b8c9d0 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -357,6 +357,7 @@ class RenderEditable extends RenderBox { ..isTextField = true; if (_selection?.isValid == true) { + config.textSelection = _selection; if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) config.onMoveCursorBackwardByCharacter = _handleMoveCursorBackwardByCharacter; if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 11ad6f0dcf8..407d1ae2059 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -90,6 +90,7 @@ class SemanticsData extends Diagnosticable { @required this.hint, @required this.textDirection, @required this.rect, + @required this.textSelection, this.tags, this.transform, }) : assert(flags != null), @@ -143,6 +144,10 @@ class SemanticsData extends Diagnosticable { /// [increasedValue], and [decreasedValue]. final TextDirection textDirection; + /// The currently selected text (or the position of the cursor) within [value] + /// if this node represents a text field. + final TextSelection textSelection; + /// The bounding box for this node in its coordinate system. final Rect rect; @@ -189,6 +194,8 @@ class SemanticsData extends Diagnosticable { properties.add(new StringProperty('decreasedValue', decreasedValue, defaultValue: '')); properties.add(new StringProperty('hint', hint, defaultValue: '')); properties.add(new EnumProperty('textDirection', textDirection, defaultValue: null)); + if (textSelection?.isValid == true) + properties.add(new MessageProperty('text selection', '[${textSelection.start}, ${textSelection.end}]')); } @override @@ -206,11 +213,12 @@ class SemanticsData extends Diagnosticable { && typedOther.textDirection == textDirection && typedOther.rect == rect && setEquals(typedOther.tags, tags) + && typedOther.textSelection == textSelection && typedOther.transform == transform; } @override - int get hashCode => ui.hashValues(flags, actions, label, value, increasedValue, decreasedValue, hint, textDirection, rect, tags, transform); + int get hashCode => ui.hashValues(flags, actions, label, value, increasedValue, decreasedValue, hint, textDirection, rect, tags, textSelection, transform); } class _SemanticsDiagnosticableNode extends DiagnosticableNode { @@ -840,6 +848,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { _increasedValue != config.increasedValue || _flags != config._flags || _textDirection != config.textDirection || + _textSelection != config._textSelection || _actionsAsBits != config._actionsAsBits || _mergeAllDescendantsIntoThisNode != config.isMergingSemanticsOfDescendants; } @@ -906,6 +915,11 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { TextDirection get textDirection => _textDirection; TextDirection _textDirection = _kEmptyConfig.textDirection; + /// The currently selected text (or the position of the cursor) within [value] + /// if this node represents a text field. + TextSelection get textSelection => _textSelection; + TextSelection _textSelection; + bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action); static final SemanticsConfiguration _kEmptyConfig = new SemanticsConfiguration(); @@ -936,6 +950,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { _textDirection = config.textDirection; _actions = new Map.from(config._actions); _actionsAsBits = config._actionsAsBits; + _textSelection = config._textSelection; _mergeAllDescendantsIntoThisNode = config.isMergingSemanticsOfDescendants; _replaceChildren(childrenInInversePaintOrder ?? const []); @@ -965,6 +980,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { String decreasedValue = _decreasedValue; TextDirection textDirection = _textDirection; Set mergedTags = tags == null ? null : new Set.from(tags); + TextSelection textSelection = _textSelection; if (mergeAllDescendantsIntoThisNode) { _visitDescendants((SemanticsNode node) { @@ -972,6 +988,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { flags |= node._flags; actions |= node._actionsAsBits; textDirection ??= node._textDirection; + textSelection ??= node._textSelection; if (value == '' || value == null) value = node._value; if (increasedValue == '' || increasedValue == null) @@ -1010,6 +1027,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { rect: rect, transform: transform, tags: mergedTags, + textSelection: textSelection, ); } @@ -1043,6 +1061,8 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { increasedValue: data.increasedValue, hint: data.hint, textDirection: data.textDirection, + textSelectionBase: data.textSelection != null ? data.textSelection.baseOffset : -1, + textSelectionExtent: data.textSelection != null ? data.textSelection.extentOffset : -1, transform: data.transform?.storage ?? _kIdentityTransform, children: children, ); @@ -1110,6 +1130,8 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { properties.add(new StringProperty('decreasedValue', _decreasedValue, defaultValue: '')); properties.add(new StringProperty('hint', _hint, defaultValue: '')); properties.add(new EnumProperty('textDirection', _textDirection, defaultValue: null)); + if (_textSelection?.isValid == true) + properties.add(new MessageProperty('text selection', '[${_textSelection.start}, ${_textSelection.end}]')); } /// Returns a string representation of this node and its descendants. @@ -1819,6 +1841,16 @@ class SemanticsConfiguration { _setFlag(SemanticsFlag.isTextField, value); } + /// The currently selected text (or the position of the cursor) within [value] + /// if this node represents a text field. + TextSelection get textSelection => _textSelection; + TextSelection _textSelection; + set textSelection(TextSelection value) { + assert(value != null); + _textSelection = value; + _hasBeenAnnotated = true; + } + // TAGS /// The set of tags that this configuration wants to add to all child @@ -1901,6 +1933,7 @@ class SemanticsConfiguration { _actions.addAll(other._actions); _actionsAsBits |= other._actionsAsBits; _flags |= other._flags; + _textSelection ??= other._textSelection; textDirection ??= other.textDirection; _label = _concatStrings( @@ -1941,6 +1974,7 @@ class SemanticsConfiguration { .._hint = _hint .._flags = _flags .._tagsForChildren = _tagsForChildren + .._textSelection = _textSelection .._actionsAsBits = _actionsAsBits .._actions.addAll(_actions); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 2cd1afcbee3..6123185797b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1808,6 +1808,7 @@ void main() { id: 2, textDirection: TextDirection.ltr, value: 'Guten Tag', + textSelection: const TextSelection.collapsed(offset: 9), actions: [ SemanticsAction.tap, SemanticsAction.moveCursorBackwardByCharacter, @@ -1828,6 +1829,7 @@ void main() { new TestSemantics.rootChild( id: 2, textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 4), value: 'Guten Tag', actions: [ SemanticsAction.tap, @@ -1851,6 +1853,7 @@ void main() { new TestSemantics.rootChild( id: 2, textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 0), value: 'Schönen Feierabend', actions: [ SemanticsAction.tap, @@ -1867,4 +1870,84 @@ void main() { semantics.dispose(); }); + testWidgets('TextField semantics for selections', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + final TextEditingController controller = new TextEditingController() + ..text = 'Hello'; + final Key key = new UniqueKey(); + + await tester.pumpWidget( + overlay( + child: new TextField( + key: key, + controller: controller, + ) + ), + ); + + expect(semantics, hasSemantics(new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: 2, + value: 'Hello', + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + ], + flags: [ + SemanticsFlag.isTextField, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + // Focus the text field + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(semantics, hasSemantics(new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: 2, + value: 'Hello', + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.moveCursorBackwardByCharacter, + ], + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3); + await tester.pump(); + + expect(semantics, hasSemantics(new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: 2, + value: 'Hello', + textSelection: const TextSelection(baseOffset: 5, extentOffset: 3), + textDirection: TextDirection.ltr, + actions: [ + SemanticsAction.tap, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + ], + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isFocused, + ], + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + } diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 58791afc63b..d99bbfde5e8 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -44,6 +44,7 @@ class TestSemantics { this.textDirection, this.rect, this.transform, + this.textSelection, this.children: const [], Iterable tags, }) : assert(flags is int || flags is List), @@ -68,6 +69,7 @@ class TestSemantics { this.hint: '', this.textDirection, this.transform, + this.textSelection, this.children: const [], Iterable tags, }) : id = 0, @@ -103,6 +105,7 @@ class TestSemantics { this.textDirection, this.rect, Matrix4 transform, + this.textSelection, this.children: const [], Iterable tags, }) : assert(flags is int || flags is List), @@ -195,6 +198,8 @@ class TestSemantics { /// parent). final Matrix4 transform; + final TextSelection textSelection; + static Matrix4 _applyRootChildScale(Matrix4 transform) { final Matrix4 result = new Matrix4.diagonal3Values(3.0, 3.0, 1.0); if (transform != null) @@ -251,6 +256,9 @@ class TestSemantics { return fail('expected node id $id to have rect $rect but found rect ${nodeData.rect}.'); if (!ignoreTransform && transform != nodeData.transform) return fail('expected node id $id to have transform $transform but found transform:\n${nodeData.transform}.'); + if (textSelection?.baseOffset != nodeData.textSelection?.baseOffset || textSelection?.extentOffset != nodeData.textSelection?.extentOffset) { + return fail('expected node id $id to have textDirection [${textSelection?.baseOffset}, ${textSelection?.end}] but found: [${nodeData.textSelection?.baseOffset}, ${nodeData.textSelection?.extentOffset}].'); + } final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; if (children.length != childrenCount) return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $childrenCount.'); @@ -293,6 +301,8 @@ class TestSemantics { buf.writeln('$indent hint: \'$hint\','); if (textDirection != null) buf.writeln('$indent textDirection: $textDirection,'); + if (textSelection?.isValid == true) + buf.writeln('$indent textSelection:\n[${textSelection.start}, ${textSelection.end}],'); if (rect != null) buf.writeln('$indent rect: $rect,'); if (transform != null)