a11y cursor movement (#13405)
This commit is contained in:
parent
ca5ab1b42a
commit
52d06b8213
@ -1 +1 @@
|
||||
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),
|
||||
)
|
||||
);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user