Unverified Commit 55c7e6e3 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Support customizing standard accessibility action hints on Android. (#19665)

parent 651c5ab3
......@@ -3207,6 +3207,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
String increasedValue,
String decreasedValue,
String hint,
SemanticsHintOverrides hintOverrides,
TextDirection textDirection,
SemanticsSortKey sortKey,
VoidCallback onTap,
......@@ -3252,6 +3253,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
_increasedValue = increasedValue,
_decreasedValue = decreasedValue,
_hint = hint,
_hintOverrides = hintOverrides,
_textDirection = textDirection,
_sortKey = sortKey,
_onTap = onTap,
......@@ -3548,6 +3550,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
markNeedsSemanticsUpdate();
}
/// If non-null, sets the [SemanticsNode.hintOverride] to the given value.
SemanticsHintOverrides get hintOverrides => _hintOverrides;
SemanticsHintOverrides _hintOverrides;
set hintOverrides(SemanticsHintOverrides value) {
if (_hintOverrides == value)
return;
_hintOverrides = value;
markNeedsSemanticsUpdate();
}
/// If non-null, sets the [SemanticsNode.textDirection] semantic to the given value.
///
/// This must not be null if [label], [hint], [value], [increasedValue], or
......@@ -3989,6 +4001,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
config.decreasedValue = decreasedValue;
if (hint != null)
config.hint = hint;
if (hintOverrides != null && hintOverrides.isNotEmpty)
config.hintOverrides = hintOverrides;
if (scopesRoute != null)
config.scopesRoute = scopesRoute;
if (namesRoute != null)
......
......@@ -83,7 +83,7 @@ class SemanticsTag {
/// these are presented in the radial context menu.
///
/// Localization and text direction do not automatically apply to the provided
/// label.
/// label or hint.
///
/// Instances of this class should either be instantiated with const or
/// new instances cached in static fields.
......@@ -98,20 +98,45 @@ class CustomSemanticsAction {
/// The [label] must not be null or the empty string.
const CustomSemanticsAction({@required this.label})
: assert(label != null),
assert(label != '');
assert(label != ''),
hint = null,
action = null;
/// The user readable name of this custom accessibility action.
/// Creates a new [CustomSemanticsAction] that overrides a standard semantics
/// action.
///
/// The [hint] must not be null or the empty string.
const CustomSemanticsAction.overridingAction({@required this.hint, @required this.action})
: assert(hint != null),
assert(hint != ''),
assert(action != null),
label = null;
/// The user readable name of this custom semantics action.
final String label;
/// The hint description of this custom semantics action.
final String hint;
/// The standard semantics action this action replaces.
final SemanticsAction action;
@override
int get hashCode => label.hashCode;
int get hashCode => ui.hashValues(label, hint, action);
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final CustomSemanticsAction typedOther = other;
return typedOther.label == label;
return typedOther.label == label
&& typedOther.hint == hint
&& typedOther.action == action;
}
@override
String toString() {
return 'CustomSemanticsAction(${_ids[this]}, label:$label, hint:$hint, action:$action)';
}
// Logic to assign a unique id to each custom action without requiring
......@@ -121,7 +146,6 @@ class CustomSemanticsAction {
static final Map<CustomSemanticsAction, int> _ids = <CustomSemanticsAction, int>{};
/// Get the identifier for a given `action`.
@visibleForTesting
static int getIdentifier(CustomSemanticsAction action) {
int result = _ids[action];
if (result == null) {
......@@ -133,7 +157,6 @@ class CustomSemanticsAction {
}
/// Get the `action` for a given identifier.
@visibleForTesting
static CustomSemanticsAction getAction(int id) {
return _actions[id];
}
......@@ -271,7 +294,8 @@ class SemanticsData extends Diagnosticable {
/// parent).
final Matrix4 transform;
/// The identifiers for the custom semantics action defined for this node.
/// The identifiers for the custom semantics actions and standard action
/// overrides for this node.
///
/// The list must be sorted in increasing order.
///
......@@ -407,6 +431,64 @@ class _SemanticsDiagnosticableNode extends DiagnosticableNode<SemanticsNode> {
}
}
/// Provides hint values which override the default hints on supported
/// platforms.
///
/// On iOS, these values are always ignored.
@immutable
class SemanticsHintOverrides extends DiagnosticableTree {
/// Creates a semantics hint overrides.
const SemanticsHintOverrides({
this.onTapHint,
this.onLongPressHint,
}) : assert(onTapHint != ''),
assert(onLongPressHint != '');
/// The hint text for a tap action.
///
/// If null, the standard hint is used instead.
///
/// The hint should describe what happens when a tap occurs, not the
/// manner in which a tap is accomplished.
///
/// Bad: 'Double tap to show movies'.
/// Good: 'show movies'.
final String onTapHint;
/// The hint text for a long press action.
///
/// If null, the standard hint is used instead.
///
/// The hint should describe what happens when a long press occurs, not
/// the manner in which the long press is accomplished.
///
/// Bad: 'Double tap and hold to show tooltip'.
/// Good: 'show tooltip'.
final String onLongPressHint;
/// Whether there are any non-null hint values.
bool get isNotEmpty => onTapHint != null || onLongPressHint != null;
@override
int get hashCode => ui.hashValues(onTapHint, onLongPressHint);
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final SemanticsHintOverrides typedOther = other;
return typedOther.onTapHint == onTapHint
&& typedOther.onLongPressHint == onLongPressHint;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(new StringProperty('onTapHint', onTapHint, defaultValue: null));
properties.add(new StringProperty('onLongPressHint', onLongPressHint, defaultValue: null));
}
}
/// Contains properties used by assistive technologies to make the application
/// more accessible.
///
......@@ -436,6 +518,7 @@ class SemanticsProperties extends DiagnosticableTree {
this.increasedValue,
this.decreasedValue,
this.hint,
this.hintOverrides,
this.textDirection,
this.sortKey,
this.onTap,
......@@ -656,6 +739,16 @@ class SemanticsProperties extends DiagnosticableTree {
/// in TalkBack and VoiceOver.
final String hint;
/// Provides hint values which override the default hints on supported
/// platforms.
///
/// On Android, If no hint overrides are used then default [hint] will be
/// combined with the [label]. Otherwise, the [hint] will be ignored as long
/// as there as at least one non-null hint override.
///
/// On iOS, these are always ignored and the default [hint] is used instead.
final SemanticsHintOverrides hintOverrides;
/// The reading direction of the [label], [value], [hint], [increasedValue],
/// and [decreasedValue].
///
......@@ -889,6 +982,7 @@ class SemanticsProperties extends DiagnosticableTree {
properties.add(new StringProperty('hint', hint));
properties.add(new EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(new DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null));
properties.add(new DiagnosticsProperty<SemanticsHintOverrides>('hintOverrides', hintOverrides));
}
@override
......@@ -1341,6 +1435,11 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
String get hint => _hint;
String _hint = _kEmptyConfig.hint;
/// Provides hint values which override the default hints on supported
/// platforms.
SemanticsHintOverrides get hintOverrides => _hintOverrides;
SemanticsHintOverrides _hintOverrides;
/// The reading direction for [label], [value], [hint], [increasedValue], and
/// [decreasedValue].
TextDirection get textDirection => _textDirection;
......@@ -1422,6 +1521,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
_value = config.value;
_increasedValue = config.increasedValue;
_hint = config.hint;
_hintOverrides = config.hintOverrides;
_flags = config._flags;
_textDirection = config.textDirection;
_sortKey = config.sortKey;
......@@ -1468,6 +1568,22 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
final Set<int> customSemanticsActionIds = new Set<int>();
for (CustomSemanticsAction action in _customSemanticsActions.keys)
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
if (hintOverrides != null) {
if (hintOverrides.onTapHint != null) {
final CustomSemanticsAction action = new CustomSemanticsAction.overridingAction(
hint: hintOverrides.onTapHint,
action: SemanticsAction.tap,
);
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
if (hintOverrides.onLongPressHint != null) {
final CustomSemanticsAction action = new CustomSemanticsAction.overridingAction(
hint: hintOverrides.onLongPressHint,
action: SemanticsAction.longPress,
);
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
}
if (mergeAllDescendantsIntoThisNode) {
_visitDescendants((SemanticsNode node) {
......@@ -1493,6 +1609,22 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
for (CustomSemanticsAction action in _customSemanticsActions.keys)
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
if (node.hintOverrides != null) {
if (node.hintOverrides.onTapHint != null) {
final CustomSemanticsAction action = new CustomSemanticsAction.overridingAction(
hint: node.hintOverrides.onTapHint,
action: SemanticsAction.tap,
);
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
if (node.hintOverrides.onLongPressHint != null) {
final CustomSemanticsAction action = new CustomSemanticsAction.overridingAction(
hint: node.hintOverrides.onLongPressHint,
action: SemanticsAction.longPress,
);
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
}
label = _concatStrings(
thisString: label,
thisTextDirection: textDirection,
......@@ -2139,8 +2271,10 @@ class SemanticsOwner extends ChangeNotifier {
node._addToUpdate(builder, customSemanticsActionIds);
}
_dirtyNodes.clear();
for (int actionId in customSemanticsActionIds)
builder.updateCustomAction(id: actionId, label: CustomSemanticsAction.getAction(actionId).label);
for (int actionId in customSemanticsActionIds) {
final CustomSemanticsAction action = CustomSemanticsAction.getAction(actionId);
builder.updateCustomAction(id: actionId, label: action.label, hint: action.hint, overrideId: action.action?.index ?? -1);
}
ui.window.updateSemantics(builder.build());
notifyListeners();
}
......@@ -2818,6 +2952,17 @@ class SemanticsConfiguration {
_hasBeenAnnotated = true;
}
/// Provides hint values which override the default hints on supported
/// platforms.
SemanticsHintOverrides get hintOverrides => _hintOverrides;
SemanticsHintOverrides _hintOverrides;
set hintOverrides(SemanticsHintOverrides value) {
if (value == null)
return;
_hintOverrides = value;
_hasBeenAnnotated = true;
}
/// Whether the semantics node is the root of a subtree for which values
/// should be announced.
///
......@@ -3138,6 +3283,7 @@ class SemanticsConfiguration {
_scrollPosition ??= other._scrollPosition;
_scrollExtentMax ??= other._scrollExtentMax;
_scrollExtentMin ??= other._scrollExtentMin;
_hintOverrides ??= other._hintOverrides;
textDirection ??= other.textDirection;
_sortKey ??= other._sortKey;
......@@ -3178,6 +3324,7 @@ class SemanticsConfiguration {
.._value = _value
.._decreasedValue = _decreasedValue
.._hint = _hint
.._hintOverrides = _hintOverrides
.._flags = _flags
.._tagsForChildren = _tagsForChildren
.._textSelection = _textSelection
......
......@@ -5098,6 +5098,8 @@ class Semantics extends SingleChildRenderObjectWidget {
String increasedValue,
String decreasedValue,
String hint,
String onTapHint,
String onLongPressHint,
TextDirection textDirection,
SemanticsSortKey sortKey,
VoidCallback onTap,
......@@ -5165,6 +5167,11 @@ class Semantics extends SingleChildRenderObjectWidget {
onDismiss: onDismiss,
onSetSelection: onSetSelection,
customSemanticsActions: customSemanticsActions,
hintOverrides: onTapHint != null || onLongPressHint != null ?
new SemanticsHintOverrides(
onTapHint: onTapHint,
onLongPressHint: onLongPressHint,
) : null,
),
);
......@@ -5248,6 +5255,7 @@ class Semantics extends SingleChildRenderObjectWidget {
increasedValue: properties.increasedValue,
decreasedValue: properties.decreasedValue,
hint: properties.hint,
hintOverrides: properties.hintOverrides,
textDirection: _getTextDirection(context),
sortKey: properties.sortKey,
onTap: properties.onTap,
......@@ -5308,6 +5316,7 @@ class Semantics extends SingleChildRenderObjectWidget {
..increasedValue = properties.increasedValue
..decreasedValue = properties.decreasedValue
..hint = properties.hint
..hintOverrides = properties.hintOverrides
..namesRoute = properties.namesRoute
..textDirection = _getTextDirection(context)
..sortKey = properties.sortKey
......
......@@ -634,6 +634,51 @@ void main() {
semantics.dispose();
});
testWidgets('onTapHint and onLongPressHint create custom actions', (WidgetTester tester) async {
final SemanticsHandle semantics = tester.ensureSemantics();
await tester.pumpWidget(new Semantics(
container: true,
onTap: () {},
onTapHint: 'test',
));
expect(tester.getSemanticsData(find.byType(Semantics)), matchesSemanticsData(
hasTapAction: true,
onTapHint: 'test'
));
await tester.pumpWidget(new Semantics(
container: true,
onLongPress: () {},
onLongPressHint: 'foo',
));
expect(tester.getSemanticsData(find.byType(Semantics)), matchesSemanticsData(
hasLongPressAction: true,
onLongPressHint: 'foo'
));
semantics.dispose();
});
testWidgets('CustomSemanticsActions can be added to a Semantics widget', (WidgetTester tester) async {
final SemanticsHandle semantics = tester.ensureSemantics();
await tester.pumpWidget(new Semantics(
container: true,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'foo'): () {},
const CustomSemanticsAction(label: 'bar'): () {}
},
));
expect(tester.getSemanticsData(find.byType(Semantics)), matchesSemanticsData(
customActions: <CustomSemanticsAction>[
const CustomSemanticsAction(label: 'bar'),
const CustomSemanticsAction(label: 'foo'),
],
));
semantics.dispose();
});
testWidgets('Increased/decreased values are annotated', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
......
......@@ -345,8 +345,11 @@ Matcher matchesSemanticsData({
bool hasPasteAction = false,
bool hasDidGainAccessibilityFocusAction = false,
bool hasDidLoseAccessibilityFocusAction = false,
bool hasCustomAction = false,
bool hasDismissAction = false,
// Custom actions and overrides
String onTapHint,
String onLongPressHint,
List<CustomSemanticsAction> customActions,
}) {
final List<SemanticsFlag> flags = <SemanticsFlag>[];
if (hasCheckedState)
......@@ -421,7 +424,7 @@ Matcher matchesSemanticsData({
actions.add(SemanticsAction.didGainAccessibilityFocus);
if (hasDidLoseAccessibilityFocusAction)
actions.add(SemanticsAction.didLoseAccessibilityFocus);
if (hasCustomAction)
if (customActions != null && customActions.isNotEmpty)
actions.add(SemanticsAction.customAction);
if (hasDismissAction)
actions.add(SemanticsAction.dismiss);
......@@ -429,6 +432,12 @@ Matcher matchesSemanticsData({
actions.add(SemanticsAction.moveCursorForwardByWord);
if (hasMoveCursorBackwardByWordAction)
actions.add(SemanticsAction.moveCursorBackwardByWord);
SemanticsHintOverrides hintOverrides;
if (onTapHint != null || onLongPressHint != null)
hintOverrides = new SemanticsHintOverrides(
onTapHint: onTapHint,
onLongPressHint: onLongPressHint,
);
return new _MatchesSemanticsData(
label: label,
......@@ -438,6 +447,8 @@ Matcher matchesSemanticsData({
flags: flags,
textDirection: textDirection,
rect: rect,
customActions: customActions,
hintOverrides: hintOverrides,
);
}
......@@ -1467,12 +1478,16 @@ class _MatchesSemanticsData extends Matcher {
this.actions,
this.textDirection,
this.rect,
this.customActions,
this.hintOverrides,
});
final String label;
final String value;
final String hint;
final SemanticsHintOverrides hintOverrides;
final List<SemanticsAction> actions;
final List<CustomSemanticsAction> customActions;
final List<SemanticsFlag> flags;
final TextDirection textDirection;
final Rect rect;
......@@ -1494,6 +1509,10 @@ class _MatchesSemanticsData extends Matcher {
description.add('with textDirection: $textDirection ');
if (rect != null)
description.add('with rect: $rect');
if (customActions != null)
description.add('with custom actions: $customActions');
if (hintOverrides != null)
description.add('with custom hints: $hintOverrides');
return description;
}
......@@ -1511,7 +1530,7 @@ class _MatchesSemanticsData extends Matcher {
return failWithDescription(matchState, 'value was: ${data.value}');
if (textDirection != null && textDirection != data.textDirection)
return failWithDescription(matchState, 'textDirection was: $textDirection');
if (rect != null && rect == data.rect) {
if (rect != null && rect != data.rect) {
return failWithDescription(matchState, 'rect was: $rect');
}
if (actions != null) {
......@@ -1527,6 +1546,27 @@ class _MatchesSemanticsData extends Matcher {
return failWithDescription(matchState, 'actions were: $actionSummary');
}
}
if (customActions != null || hintOverrides != null) {
final List<CustomSemanticsAction> providedCustomActions = data.customSemanticsActionIds.map((int id) {
return CustomSemanticsAction.getAction(id);
}).toList();
final List<CustomSemanticsAction> expectedCustomActions = new List<CustomSemanticsAction>.from(customActions ?? const <int>[]);
if (hintOverrides?.onTapHint != null)
expectedCustomActions.add(new CustomSemanticsAction.overridingAction(hint: hintOverrides.onTapHint, action: SemanticsAction.tap));
if (hintOverrides?.onLongPressHint != null)
expectedCustomActions.add(new CustomSemanticsAction.overridingAction(hint: hintOverrides.onLongPressHint, action: SemanticsAction.longPress));
if (expectedCustomActions.length != providedCustomActions.length)
return failWithDescription(matchState, 'custom actions where: $providedCustomActions');
int sortActions(CustomSemanticsAction left, CustomSemanticsAction right) {
return CustomSemanticsAction.getIdentifier(left) - CustomSemanticsAction.getIdentifier(right);
}
expectedCustomActions.sort(sortActions);
providedCustomActions.sort(sortActions);
for (int i = 0; i < expectedCustomActions.length; i++) {
if (expectedCustomActions[i] != providedCustomActions[i])
return failWithDescription(matchState, 'custom actions where: $providedCustomActions');
}
}
if (flags != null) {
int flagBits = 0;
for (SemanticsFlag flag in flags)
......
......@@ -394,10 +394,17 @@ void main() {
header: true,
button: true,
onTap: () {},
onLongPress: () {},
label: 'foo',
hint: 'bar',
value: 'baz',
textDirection: TextDirection.rtl,
onTapHint: 'scan',
onLongPressHint: 'fill',
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'foo'): () {},
const CustomSemanticsAction(label: 'bar'): () {},
},
));
expect(tester.getSemanticsData(find.byKey(key)),
......@@ -407,17 +414,68 @@ void main() {
value: 'baz',
textDirection: TextDirection.rtl,
hasTapAction: true,
hasLongPressAction: true,
isButton: true,
isHeader: true,
namesRoute: true,
onTapHint: 'scan',
onLongPressHint: 'fill',
customActions: <CustomSemanticsAction>[
const CustomSemanticsAction(label: 'foo'),
const CustomSemanticsAction(label: 'bar')
],
),
);
// Doesn't match custom actions
expect(tester.getSemanticsData(find.byKey(key)),
isNot(matchesSemanticsData(
label: 'foo',
hint: 'bar',
value: 'baz',
textDirection: TextDirection.rtl,
hasTapAction: true,
hasLongPressAction: true,
isButton: true,
isHeader: true,
namesRoute: true,
onTapHint: 'scan',
onLongPressHint: 'fill',
customActions: <CustomSemanticsAction>[
const CustomSemanticsAction(label: 'foo'),
const CustomSemanticsAction(label: 'barz')
],
)),
);
// Doesn't match wrong hints
expect(tester.getSemanticsData(find.byKey(key)),
isNot(matchesSemanticsData(
label: 'foo',
hint: 'bar',
value: 'baz',
textDirection: TextDirection.rtl,
hasTapAction: true,
hasLongPressAction: true,
isButton: true,
isHeader: true,
namesRoute: true,
onTapHint: 'scans',
onLongPressHint: 'fills',
customActions: <CustomSemanticsAction>[
const CustomSemanticsAction(label: 'foo'),
const CustomSemanticsAction(label: 'bar')
],
)),
);
handle.dispose();
});
testWidgets('Can match all semantics flags and actions', (WidgetTester tester) async {
int actions = 0;
int flags = 0;
const CustomSemanticsAction action = const CustomSemanticsAction(label: 'test');
for (int index in SemanticsAction.values.keys)
actions |= index;
for (int index in SemanticsFlag.values.keys)
......@@ -436,6 +494,7 @@ void main() {
scrollPosition: null,
scrollExtentMax: null,
scrollExtentMin: null,
customSemanticsActionIds: <int>[CustomSemanticsAction.getIdentifier(action)],
);
expect(data, matchesSemanticsData(
......@@ -478,8 +537,8 @@ void main() {
hasPasteAction: true,
hasDidGainAccessibilityFocusAction: true,
hasDidLoseAccessibilityFocusAction: true,
hasCustomAction: true,
hasDismissAction: true,
customActions: <CustomSemanticsAction>[action],
));
});
});
......
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