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> { ...@@ -387,10 +387,18 @@ class _TextFieldState extends State<TextField> {
); );
} }
return new GestureDetector( return new Semantics(
onTap: () {
if (!_controller.selection.isValid)
_controller.selection = new TextSelection.collapsed(offset: _controller.text.length);
_requestKeyboard();
},
child: new GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: _requestKeyboard, onTap: _requestKeyboard,
child: child, child: child,
excludeFromSemantics: true,
),
); );
} }
} }
...@@ -401,6 +401,26 @@ class TextPainter { ...@@ -401,6 +401,26 @@ class TextPainter {
return value & 0xF800 == 0xD800; 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) { Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) {
final int prevCodeUnit = _text.codeUnitAt(offset - 1); final int prevCodeUnit = _text.codeUnitAt(offset - 1);
if (prevCodeUnit == null) if (prevCodeUnit == null)
......
...@@ -845,6 +845,12 @@ class RenderCustomPaint extends RenderProxyBox { ...@@ -845,6 +845,12 @@ class RenderCustomPaint extends RenderProxyBox {
if (properties.onDecrease != null) { if (properties.onDecrease != null) {
config.addAction(SemanticsAction.decrease, properties.onDecrease); 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( newChild.updateWith(
config: config, config: config,
......
...@@ -316,6 +316,7 @@ class RenderEditable extends RenderBox { ...@@ -316,6 +316,7 @@ class RenderEditable extends RenderBox {
_selection = value; _selection = value;
_selectionRects = null; _selectionRects = null;
markNeedsPaint(); markNeedsPaint();
markNeedsSemanticsUpdate();
} }
/// The offset at which the text should be painted. /// The offset at which the text should be painted.
...@@ -346,6 +347,30 @@ class RenderEditable extends RenderBox { ...@@ -346,6 +347,30 @@ class RenderEditable extends RenderBox {
..textDirection = textDirection ..textDirection = textDirection
..isFocused = hasFocus ..isFocused = hasFocus
..isTextField = true; ..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 @override
......
...@@ -2830,6 +2830,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -2830,6 +2830,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
VoidCallback onScrollDown, VoidCallback onScrollDown,
VoidCallback onIncrease, VoidCallback onIncrease,
VoidCallback onDecrease, VoidCallback onDecrease,
VoidCallback onMoveCursorForwardByCharacter,
VoidCallback onMoveCursorBackwardByCharacter,
}) : assert(container != null), }) : assert(container != null),
_container = container, _container = container,
_explicitChildNodes = explicitChildNodes, _explicitChildNodes = explicitChildNodes,
...@@ -2850,6 +2852,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -2850,6 +2852,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
_onScrollDown = onScrollDown, _onScrollDown = onScrollDown,
_onIncrease = onIncrease, _onIncrease = onIncrease,
_onDecrease = onDecrease, _onDecrease = onDecrease,
_onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter,
_onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter,
super(child); super(child);
/// If 'container' is true, this [RenderObject] will introduce a new /// If 'container' is true, this [RenderObject] will introduce a new
...@@ -3162,6 +3166,42 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3162,6 +3166,42 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
markNeedsSemanticsUpdate(); 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 @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
config.isSemanticBoundary = container; config.isSemanticBoundary = container;
...@@ -3204,6 +3244,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3204,6 +3244,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
config.addAction(SemanticsAction.increase, _performIncrease); config.addAction(SemanticsAction.increase, _performIncrease);
if (onDecrease != null) if (onDecrease != null)
config.addAction(SemanticsAction.decrease, _performDecrease); config.addAction(SemanticsAction.decrease, _performDecrease);
if (onMoveCursorForwardByCharacter != null)
config.addAction(SemanticsAction.moveCursorForwardByCharacter, _performMoveCursorForwardByCharacter);
if (onMoveCursorBackwardByCharacter != null)
config.addAction(SemanticsAction.moveCursorBackwardByCharacter, _performMoveCursorBackwardByCharacter);
} }
void _performTap() { void _performTap() {
...@@ -3245,6 +3289,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3245,6 +3289,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
if (onDecrease != null) if (onDecrease != null)
onDecrease(); 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 /// Causes the semantics of all earlier render objects below the same semantic
......
...@@ -252,6 +252,8 @@ class SemanticsProperties extends DiagnosticableTree { ...@@ -252,6 +252,8 @@ class SemanticsProperties extends DiagnosticableTree {
this.onScrollDown, this.onScrollDown,
this.onIncrease, this.onIncrease,
this.onDecrease, this.onDecrease,
this.onMoveCursorForwardByCharacter,
this.onMoveCursorBackwardByCharacter,
}); });
/// If non-null, indicates that this subtree represents a checkbox /// If non-null, indicates that this subtree represents a checkbox
...@@ -436,6 +438,24 @@ class SemanticsProperties extends DiagnosticableTree { ...@@ -436,6 +438,24 @@ class SemanticsProperties extends DiagnosticableTree {
/// volume down button. /// volume down button.
final VoidCallback onDecrease; 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 @override
void debugFillProperties(DiagnosticPropertiesBuilder description) { void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description); super.debugFillProperties(description);
......
...@@ -4726,6 +4726,8 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -4726,6 +4726,8 @@ class Semantics extends SingleChildRenderObjectWidget {
VoidCallback onScrollDown, VoidCallback onScrollDown,
VoidCallback onIncrease, VoidCallback onIncrease,
VoidCallback onDecrease, VoidCallback onDecrease,
VoidCallback onMoveCursorForwardByCharacter,
VoidCallback onMoveCursorBackwardByCharacter,
}) : this.fromProperties( }) : this.fromProperties(
key: key, key: key,
child: child, child: child,
...@@ -4749,6 +4751,8 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -4749,6 +4751,8 @@ class Semantics extends SingleChildRenderObjectWidget {
onScrollDown: onScrollDown, onScrollDown: onScrollDown,
onIncrease: onIncrease, onIncrease: onIncrease,
onDecrease: onDecrease, onDecrease: onDecrease,
onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter,
onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter,
), ),
); );
...@@ -4814,6 +4818,8 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -4814,6 +4818,8 @@ class Semantics extends SingleChildRenderObjectWidget {
onScrollDown: properties.onScrollDown, onScrollDown: properties.onScrollDown,
onIncrease: properties.onIncrease, onIncrease: properties.onIncrease,
onDecrease: properties.onDecrease, onDecrease: properties.onDecrease,
onMoveCursorForwardByCharacter: properties.onMoveCursorForwardByCharacter,
onMoveCursorBackwardByCharacter: properties.onMoveCursorBackwardByCharacter,
); );
} }
...@@ -4849,7 +4855,9 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -4849,7 +4855,9 @@ class Semantics extends SingleChildRenderObjectWidget {
..onScrollUp = properties.onScrollUp ..onScrollUp = properties.onScrollUp
..onScrollDown = properties.onScrollDown ..onScrollDown = properties.onScrollDown
..onIncrease = properties.onIncrease ..onIncrease = properties.onIncrease
..onDecrease = properties.onDecrease; ..onDecrease = properties.onDecrease
..onMoveCursorForwardByCharacter = properties.onMoveCursorForwardByCharacter
..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter;
} }
@override @override
......
...@@ -356,4 +356,122 @@ void main() { ...@@ -356,4 +356,122 @@ void main() {
expect(textState.selectionOverlay.handlesAreVisible, isFalse); expect(textState.selectionOverlay.handlesAreVisible, isFalse);
expect(textState.selectionOverlay.textEditingValue.selection, const TextSelection.collapsed(offset: 10)); 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() { ...@@ -388,6 +388,8 @@ void main() {
onScrollDown: () => performedActions.add(SemanticsAction.scrollDown), onScrollDown: () => performedActions.add(SemanticsAction.scrollDown),
onIncrease: () => performedActions.add(SemanticsAction.increase), onIncrease: () => performedActions.add(SemanticsAction.increase),
onDecrease: () => performedActions.add(SemanticsAction.decrease), 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