Commit 52d06b82 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by Chris Bracken

a11y cursor movement (#13405)

parent ca5ab1b4
f888186e50f20fdb49ceea0dae74b6443a22ddaa
9d5cd4b12ec7fc29ad2117bf7851844be861b74d
......@@ -387,10 +387,18 @@ class _TextFieldState extends State<TextField> {
);
}
return new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _requestKeyboard,
child: child,
return new Semantics(
onTap: () {
if (!_controller.selection.isValid)
_controller.selection = new TextSelection.collapsed(offset: _controller.text.length);
_requestKeyboard();
},
child: new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _requestKeyboard,
child: child,
excludeFromSemantics: true,
),
);
}
}
......@@ -401,6 +401,26 @@ class TextPainter {
return value & 0xF800 == 0xD800;
}
/// Returns the closest offset after `offset` at which the inout cursor can be
/// positioned.
int getOffsetAfter(int offset) {
final int nextCodeUnit = _text.codeUnitAt(offset);
if (nextCodeUnit == null)
return null;
// TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404).
return _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1;
}
/// Returns the closest offset before `offset` at which the inout cursor can
/// be positioned.
int getOffsetBefore(int offset) {
final int prevCodeUnit = _text.codeUnitAt(offset - 1);
if (prevCodeUnit == null)
return null;
// TODO(goderbauer): doesn't handle flag emojis (https://github.com/flutter/flutter/issues/13404).
return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1;
}
Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) {
final int prevCodeUnit = _text.codeUnitAt(offset - 1);
if (prevCodeUnit == null)
......
......@@ -845,6 +845,12 @@ class RenderCustomPaint extends RenderProxyBox {
if (properties.onDecrease != null) {
config.addAction(SemanticsAction.decrease, properties.onDecrease);
}
if (properties.onMoveCursorForwardByCharacter != null) {
config.addAction(SemanticsAction.moveCursorForwardByCharacter, properties.onMoveCursorForwardByCharacter);
}
if (properties.onMoveCursorBackwardByCharacter != null) {
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, properties.onMoveCursorBackwardByCharacter);
}
newChild.updateWith(
config: config,
......
......@@ -316,6 +316,7 @@ class RenderEditable extends RenderBox {
_selection = value;
_selectionRects = null;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
/// The offset at which the text should be painted.
......@@ -346,6 +347,30 @@ class RenderEditable extends RenderBox {
..textDirection = textDirection
..isFocused = hasFocus
..isTextField = true;
if (_selection?.isValid == true) {
if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) {
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, () {
final int offset = _textPainter.getOffsetBefore(_selection.extentOffset);
if (offset == null)
return;
onSelectionChanged(
new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard,
);
});
}
if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) {
config.addAction(SemanticsAction.moveCursorForwardByCharacter, () {
final int offset = _textPainter.getOffsetAfter(_selection.extentOffset);
if (offset == null)
return;
onSelectionChanged(
new TextSelection.collapsed(offset: offset), this, SelectionChangedCause.keyboard,
);
});
}
}
}
@override
......
......@@ -2830,6 +2830,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
VoidCallback onScrollDown,
VoidCallback onIncrease,
VoidCallback onDecrease,
VoidCallback onMoveCursorForwardByCharacter,
VoidCallback onMoveCursorBackwardByCharacter,
}) : assert(container != null),
_container = container,
_explicitChildNodes = explicitChildNodes,
......@@ -2850,6 +2852,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
_onScrollDown = onScrollDown,
_onIncrease = onIncrease,
_onDecrease = onDecrease,
_onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter,
_onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter,
super(child);
/// If 'container' is true, this [RenderObject] will introduce a new
......@@ -3162,6 +3166,42 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
markNeedsSemanticsUpdate();
}
/// The handler for [SemanticsAction.onMoveCursorForwardByCharacter].
///
/// This handler is invoked when the user wants to move the cursor in a
/// text field forward by one character.
///
/// TalkBack users can trigger this by pressing the volume up key while the
/// input focus is in a text field.
VoidCallback get onMoveCursorForwardByCharacter => _onMoveCursorForwardByCharacter;
VoidCallback _onMoveCursorForwardByCharacter;
set onMoveCursorForwardByCharacter(VoidCallback handler) {
if (_onMoveCursorForwardByCharacter == handler)
return;
final bool hadValue = _onMoveCursorForwardByCharacter != null;
_onMoveCursorForwardByCharacter = handler;
if ((handler != null) != hadValue)
markNeedsSemanticsUpdate();
}
/// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter].
///
/// This handler is invoked when the user wants to move the cursor in a
/// text field backward by one character.
///
/// TalkBack users can trigger this by pressing the volume down key while the
/// input focus is in a text field.
VoidCallback get onMoveCursorBackwardByCharacter => _onMoveCursorBackwardByCharacter;
VoidCallback _onMoveCursorBackwardByCharacter;
set onMoveCursorBackwardByCharacter(VoidCallback handler) {
if (_onMoveCursorBackwardByCharacter == handler)
return;
final bool hadValue = _onMoveCursorBackwardByCharacter != null;
_onMoveCursorBackwardByCharacter = handler;
if ((handler != null) != hadValue)
markNeedsSemanticsUpdate();
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
config.isSemanticBoundary = container;
......@@ -3204,6 +3244,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
config.addAction(SemanticsAction.increase, _performIncrease);
if (onDecrease != null)
config.addAction(SemanticsAction.decrease, _performDecrease);
if (onMoveCursorForwardByCharacter != null)
config.addAction(SemanticsAction.moveCursorForwardByCharacter, _performMoveCursorForwardByCharacter);
if (onMoveCursorBackwardByCharacter != null)
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, _performMoveCursorBackwardByCharacter);
}
void _performTap() {
......@@ -3245,6 +3289,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
if (onDecrease != null)
onDecrease();
}
void _performMoveCursorForwardByCharacter() {
if (onMoveCursorForwardByCharacter != null)
onMoveCursorForwardByCharacter();
}
void _performMoveCursorBackwardByCharacter() {
if (onMoveCursorBackwardByCharacter != null)
onMoveCursorBackwardByCharacter();
}
}
/// Causes the semantics of all earlier render objects below the same semantic
......
......@@ -252,6 +252,8 @@ class SemanticsProperties extends DiagnosticableTree {
this.onScrollDown,
this.onIncrease,
this.onDecrease,
this.onMoveCursorForwardByCharacter,
this.onMoveCursorBackwardByCharacter,
});
/// If non-null, indicates that this subtree represents a checkbox
......@@ -436,6 +438,24 @@ class SemanticsProperties extends DiagnosticableTree {
/// volume down button.
final VoidCallback onDecrease;
/// The handler for [SemanticsAction.onMoveCursorForwardByCharacter].
///
/// This handler is invoked when the user wants to move the cursor in a
/// text field forward by one character.
///
/// TalkBack users can trigger this by pressing the volume up key while the
/// input focus is in a text field.
final VoidCallback onMoveCursorForwardByCharacter;
/// The handler for [SemanticsAction.onMoveCursorBackwardByCharacter].
///
/// This handler is invoked when the user wants to move the cursor in a
/// text field backward by one character.
///
/// TalkBack users can trigger this by pressing the volume down key while the
/// input focus is in a text field.
final VoidCallback onMoveCursorBackwardByCharacter;
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
......
......@@ -4726,6 +4726,8 @@ class Semantics extends SingleChildRenderObjectWidget {
VoidCallback onScrollDown,
VoidCallback onIncrease,
VoidCallback onDecrease,
VoidCallback onMoveCursorForwardByCharacter,
VoidCallback onMoveCursorBackwardByCharacter,
}) : this.fromProperties(
key: key,
child: child,
......@@ -4749,6 +4751,8 @@ class Semantics extends SingleChildRenderObjectWidget {
onScrollDown: onScrollDown,
onIncrease: onIncrease,
onDecrease: onDecrease,
onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter,
onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter,
),
);
......@@ -4814,6 +4818,8 @@ class Semantics extends SingleChildRenderObjectWidget {
onScrollDown: properties.onScrollDown,
onIncrease: properties.onIncrease,
onDecrease: properties.onDecrease,
onMoveCursorForwardByCharacter: properties.onMoveCursorForwardByCharacter,
onMoveCursorBackwardByCharacter: properties.onMoveCursorBackwardByCharacter,
);
}
......@@ -4849,7 +4855,9 @@ class Semantics extends SingleChildRenderObjectWidget {
..onScrollUp = properties.onScrollUp
..onScrollDown = properties.onScrollDown
..onIncrease = properties.onIncrease
..onDecrease = properties.onDecrease;
..onDecrease = properties.onDecrease
..onMoveCursorForwardByCharacter = properties.onMoveCursorForwardByCharacter
..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter;
}
@override
......
......@@ -356,4 +356,122 @@ void main() {
expect(textState.selectionOverlay.handlesAreVisible, isFalse);
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10));
});
testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
controller.text = 'test';
await tester.pumpWidget(new MaterialApp(
home: new EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
));
expect(semantics, includesNodeWith(
value: 'test',
));
controller.selection = new TextSelection.collapsed(offset: controller.text.length);
await tester.pumpAndSettle();
// At end, can only go backwards.
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
],
));
controller.selection = new TextSelection.collapsed(offset: controller.text.length - 2);
await tester.pumpAndSettle();
// Somewhere in the middle, can go in both directions.
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.moveCursorForwardByCharacter,
],
));
controller.selection = const TextSelection.collapsed(offset: 0);
await tester.pumpAndSettle();
// At beginning, can only go forward.
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorForwardByCharacter,
],
));
semantics.dispose();
});
testWidgets('can move cursor with a11y means', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
controller.text = 'test';
controller.selection = new TextSelection.collapsed(offset: controller.text.length);
await tester.pumpWidget(new MaterialApp(
home: new EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
));
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
],
));
final RenderEditable render = tester.allRenderObjects.firstWhere((RenderObject o) => o.runtimeType == RenderEditable);
final int semanticsId = render.debugSemantics.id;
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 4);
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 3);
expect(controller.selection.extentOffset, 3);
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.moveCursorForwardByCharacter,
],
));
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
await tester.pumpAndSettle();
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
await tester.pumpAndSettle();
tester.binding.pipelineOwner.semanticsOwner.performAction(semanticsId, SemanticsAction.moveCursorBackwardByCharacter);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 0);
await tester.pumpAndSettle();
expect(semantics, includesNodeWith(
value: 'test',
actions: <SemanticsAction>[
SemanticsAction.moveCursorForwardByCharacter,
],
));
semantics.dispose();
});
}
......@@ -388,6 +388,8 @@ void main() {
onScrollDown: () => performedActions.add(SemanticsAction.scrollDown),
onIncrease: () => performedActions.add(SemanticsAction.increase),
onDecrease: () => performedActions.add(SemanticsAction.decrease),
onMoveCursorForwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorForwardByCharacter),
onMoveCursorBackwardByCharacter: () => performedActions.add(SemanticsAction.moveCursorBackwardByCharacter),
)
);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment