Unverified Commit d2d17abe authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Add support for custom semantics actions to Android and iOS. (#18882)

parent 924c206c
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'package:collection/collection.dart' show lowerBound; import 'package:collection/collection.dart' show lowerBound;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
enum LeaveBehindDemoAction { enum LeaveBehindDemoAction {
reset, reset,
...@@ -85,52 +86,55 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> { ...@@ -85,52 +86,55 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> {
}); });
} }
Widget buildItem(LeaveBehindItem item) { void _handleArchive(LeaveBehindItem item) {
final ThemeData theme = Theme.of(context); setState(() {
return new Dismissible( leaveBehindItems.remove(item);
key: new ObjectKey(item), });
direction: _dismissDirection, _scaffoldKey.currentState.showSnackBar(new SnackBar(
onDismissed: (DismissDirection direction) { content: new Text('You archived item ${item.index}'),
setState(() { action: new SnackBarAction(
leaveBehindItems.remove(item); label: 'UNDO',
}); onPressed: () { handleUndo(item); }
final String action = (direction == DismissDirection.endToStart) ? 'archived' : 'deleted';
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('You $action item ${item.index}'),
action: new SnackBarAction(
label: 'UNDO',
onPressed: () { handleUndo(item); }
)
));
},
background: new Container(
color: theme.primaryColor,
child: const ListTile(
leading: const Icon(Icons.delete, color: Colors.white, size: 36.0)
)
),
secondaryBackground: new Container(
color: theme.primaryColor,
child: const ListTile(
trailing: const Icon(Icons.archive, color: Colors.white, size: 36.0)
)
),
child: new Container(
decoration: new BoxDecoration(
color: theme.canvasColor,
border: new Border(bottom: new BorderSide(color: theme.dividerColor))
),
child: new ListTile(
title: new Text(item.name),
subtitle: new Text('${item.subject}\n${item.body}'),
isThreeLine: true
)
) )
); ));
}
void _handleDelete(LeaveBehindItem item) {
setState(() {
leaveBehindItems.remove(item);
});
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('You deleted item ${item.index}'),
action: new SnackBarAction(
label: 'UNDO',
onPressed: () { handleUndo(item); }
)
));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget body;
if (leaveBehindItems.isEmpty) {
body = new Center(
child: new RaisedButton(
onPressed: () => handleDemoAction(LeaveBehindDemoAction.reset),
child: const Text('Reset the list'),
),
);
} else {
body = new ListView(
children: leaveBehindItems.map((LeaveBehindItem item) {
return new _LeaveBehindListItem(
item: item,
onArchive: _handleArchive,
onDelete: _handleDelete,
dismissDirection: _dismissDirection,
);
}).toList()
);
}
return new Scaffold( return new Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
appBar: new AppBar( appBar: new AppBar(
...@@ -163,16 +167,74 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> { ...@@ -163,16 +167,74 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> {
) )
] ]
), ),
body: leaveBehindItems.isEmpty body: body,
? new Center( );
child: new RaisedButton( }
onPressed: () => handleDemoAction(LeaveBehindDemoAction.reset), }
child: const Text('Reset the list'),
), class _LeaveBehindListItem extends StatelessWidget {
) const _LeaveBehindListItem({
: new ListView( Key key,
children: leaveBehindItems.map(buildItem).toList() @required this.item,
), @required this.onArchive,
@required this.onDelete,
@required this.dismissDirection,
}) : super(key: key);
final LeaveBehindItem item;
final DismissDirection dismissDirection;
final void Function(LeaveBehindItem) onArchive;
final void Function(LeaveBehindItem) onDelete;
void _handleArchive() {
onArchive(item);
}
void _handleDelete() {
onDelete(item);
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return new Semantics(
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'Archive'): _handleArchive,
const CustomSemanticsAction(label: 'Delete'): _handleDelete,
},
child: new Dismissible(
key: new ObjectKey(item),
direction: dismissDirection,
onDismissed: (DismissDirection direction) {
if (direction == DismissDirection.endToStart)
_handleArchive();
else
_handleDelete();
},
background: new Container(
color: theme.primaryColor,
child: const ListTile(
leading: const Icon(Icons.delete, color: Colors.white, size: 36.0)
)
),
secondaryBackground: new Container(
color: theme.primaryColor,
child: const ListTile(
trailing: const Icon(Icons.archive, color: Colors.white, size: 36.0)
)
),
child: new Container(
decoration: new BoxDecoration(
color: theme.canvasColor,
border: new Border(bottom: new BorderSide(color: theme.dividerColor))
),
child: new ListTile(
title: new Text(item.name),
subtitle: new Text('${item.subject}\n${item.body}'),
isThreeLine: true
),
),
),
); );
} }
} }
...@@ -3159,6 +3159,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3159,6 +3159,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
SetSelectionHandler onSetSelection, SetSelectionHandler onSetSelection,
VoidCallback onDidGainAccessibilityFocus, VoidCallback onDidGainAccessibilityFocus,
VoidCallback onDidLoseAccessibilityFocus, VoidCallback onDidLoseAccessibilityFocus,
Map<CustomSemanticsAction, VoidCallback> customSemanticsActions,
}) : assert(container != null), }) : assert(container != null),
_container = container, _container = container,
_explicitChildNodes = explicitChildNodes, _explicitChildNodes = explicitChildNodes,
...@@ -3197,6 +3198,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3197,6 +3198,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
_onSetSelection = onSetSelection, _onSetSelection = onSetSelection,
_onDidGainAccessibilityFocus = onDidGainAccessibilityFocus, _onDidGainAccessibilityFocus = onDidGainAccessibilityFocus,
_onDidLoseAccessibilityFocus = onDidLoseAccessibilityFocus, _onDidLoseAccessibilityFocus = onDidLoseAccessibilityFocus,
_customSemanticsActions = customSemanticsActions,
super(child); super(child);
/// If 'container' is true, this [RenderObject] will introduce a new /// If 'container' is true, this [RenderObject] will introduce a new
...@@ -3779,6 +3781,24 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3779,6 +3781,24 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
markNeedsSemanticsUpdate(); markNeedsSemanticsUpdate();
} }
/// The handlers and supported [CustomSemanticsAction]s for this node.
///
/// These handlers are called whenever the user performs the associated
/// custom accessibility action from a special platform menu. Providing any
/// custom actions here also adds [SemanticsAction.customAction] to the node.
///
/// See also:
///
/// * [CustomSemanticsAction], for an explaination of custom actions.
Map<CustomSemanticsAction, VoidCallback> get customSemanticsActions => _customSemanticsActions;
Map<CustomSemanticsAction, VoidCallback> _customSemanticsActions;
set customSemanticsActions(Map<CustomSemanticsAction, VoidCallback> value) {
if (_customSemanticsActions == value)
return;
_customSemanticsActions = value;
markNeedsSemanticsUpdate();
}
@override @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config); super.describeSemanticsConfiguration(config);
...@@ -3860,6 +3880,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ...@@ -3860,6 +3880,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
config.onDidGainAccessibilityFocus = _performDidGainAccessibilityFocus; config.onDidGainAccessibilityFocus = _performDidGainAccessibilityFocus;
if (onDidLoseAccessibilityFocus != null) if (onDidLoseAccessibilityFocus != null)
config.onDidLoseAccessibilityFocus = _performDidLoseAccessibilityFocus; config.onDidLoseAccessibilityFocus = _performDidLoseAccessibilityFocus;
if (customSemanticsActions != null)
config.customSemanticsActions = _customSemanticsActions;
} }
void _performTap() { void _performTap() {
......
...@@ -69,6 +69,76 @@ class SemanticsTag { ...@@ -69,6 +69,76 @@ class SemanticsTag {
String toString() => '$runtimeType($name)'; String toString() => '$runtimeType($name)';
} }
/// An identifier of a custom semantics action.
///
/// Custom semantics actions can be provided to make complex user
/// interactions more accessible. For instance, if an application has a
/// drag-and-drop list that requires the user to press and hold an item
/// to move it, users interacting with the application using a hardware
/// switch may have difficulty. This can be made accessible by creating custom
/// actions and pairing them with handlers that move a list item up or down in
/// the list.
///
/// In Android, these actions are presented in the local context menu. In iOS,
/// these are presented in the radial context menu.
///
/// Localization and text direction do not automatically apply to the provided
/// label.
///
/// Instances of this class should either be instantiated with const or
/// new instances cached in static fields.
///
/// See also:
///
/// * [SemanticsProperties], where the handler for a custom action is provided.
@immutable
class CustomSemanticsAction {
/// Creates a new [CustomSemanticsAction].
///
/// The [label] must not be null or the empty string.
const CustomSemanticsAction({@required this.label})
: assert(label != null),
assert(label != '');
/// The user readable name of this custom accessibility action.
final String label;
@override
int get hashCode => label.hashCode;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType)
return false;
final CustomSemanticsAction typedOther = other;
return typedOther.label == label;
}
// Logic to assign a unique id to each custom action without requiring
// user specification.
static int _nextId = 0;
static final Map<int, CustomSemanticsAction> _actions = <int, 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) {
result = _nextId++;
_ids[action] = result;
_actions[result] = action;
}
return result;
}
/// Get the `action` for a given identifier.
@visibleForTesting
static CustomSemanticsAction getAction(int id) {
return _actions[id];
}
}
/// Summary information about a [SemanticsNode] object. /// Summary information about a [SemanticsNode] object.
/// ///
/// A semantics node might [SemanticsNode.mergeAllDescendantsIntoThisNode], /// A semantics node might [SemanticsNode.mergeAllDescendantsIntoThisNode],
...@@ -100,6 +170,7 @@ class SemanticsData extends Diagnosticable { ...@@ -100,6 +170,7 @@ class SemanticsData extends Diagnosticable {
@required this.scrollExtentMin, @required this.scrollExtentMin,
this.tags, this.tags,
this.transform, this.transform,
this.customSemanticsActionIds,
}) : assert(flags != null), }) : assert(flags != null),
assert(actions != null), assert(actions != null),
assert(label != null), assert(label != null),
...@@ -200,6 +271,15 @@ class SemanticsData extends Diagnosticable { ...@@ -200,6 +271,15 @@ class SemanticsData extends Diagnosticable {
/// parent). /// parent).
final Matrix4 transform; final Matrix4 transform;
/// The identifiers for the custom semantics action defined for this node.
///
/// The list must be sorted in increasing order.
///
/// See also:
///
/// * [CustomSemanticsAction], for an explanation of custom actions.
final List<int> customSemanticsActionIds;
/// Whether [flags] contains the given flag. /// Whether [flags] contains the given flag.
bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0; bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0;
...@@ -219,7 +299,11 @@ class SemanticsData extends Diagnosticable { ...@@ -219,7 +299,11 @@ class SemanticsData extends Diagnosticable {
if ((actions & action.index) != 0) if ((actions & action.index) != 0)
actionSummary.add(describeEnum(action)); actionSummary.add(describeEnum(action));
} }
final List<String> customSemanticsActionSummary = customSemanticsActionIds
.map<String>((int actionId) => CustomSemanticsAction.getAction(actionId).label)
.toList();
properties.add(new IterableProperty<String>('actions', actionSummary, ifEmpty: null)); properties.add(new IterableProperty<String>('actions', actionSummary, ifEmpty: null));
properties.add(new IterableProperty<String>('customActions', customSemanticsActionSummary, ifEmpty: null));
final List<String> flagSummary = <String>[]; final List<String> flagSummary = <String>[];
for (SemanticsFlag flag in SemanticsFlag.values.values) { for (SemanticsFlag flag in SemanticsFlag.values.values) {
...@@ -259,11 +343,45 @@ class SemanticsData extends Diagnosticable { ...@@ -259,11 +343,45 @@ class SemanticsData extends Diagnosticable {
&& typedOther.scrollPosition == scrollPosition && typedOther.scrollPosition == scrollPosition
&& typedOther.scrollExtentMax == scrollExtentMax && typedOther.scrollExtentMax == scrollExtentMax
&& typedOther.scrollExtentMin == scrollExtentMin && typedOther.scrollExtentMin == scrollExtentMin
&& typedOther.transform == transform; && typedOther.transform == transform
&& _sortedListsEqual(typedOther.customSemanticsActionIds, customSemanticsActionIds);
} }
@override @override
int get hashCode => ui.hashValues(flags, actions, label, value, increasedValue, decreasedValue, hint, textDirection, rect, tags, textSelection, scrollPosition, scrollExtentMax, scrollExtentMin, transform); int get hashCode {
return ui.hashValues(
flags,
actions,
label,
value,
increasedValue,
decreasedValue,
hint,
textDirection,
rect,
tags,
textSelection,
scrollPosition,
scrollExtentMax,
scrollExtentMin,
transform,
customSemanticsActionIds,
);
}
static bool _sortedListsEqual(List<int> left, List<int> right) {
if (left == null && right == null)
return true;
if (left != null && right != null) {
if (left.length != right.length)
return false;
for (int i = 0; i < left.length; i++)
if (left[i] != right[i])
return false;
return true;
}
return false;
}
} }
class _SemanticsDiagnosticableNode extends DiagnosticableNode<SemanticsNode> { class _SemanticsDiagnosticableNode extends DiagnosticableNode<SemanticsNode> {
...@@ -333,6 +451,7 @@ class SemanticsProperties extends DiagnosticableTree { ...@@ -333,6 +451,7 @@ class SemanticsProperties extends DiagnosticableTree {
this.onSetSelection, this.onSetSelection,
this.onDidGainAccessibilityFocus, this.onDidGainAccessibilityFocus,
this.onDidLoseAccessibilityFocus, this.onDidLoseAccessibilityFocus,
this.customSemanticsActions,
}); });
/// If non-null, indicates that this subtree represents something that can be /// If non-null, indicates that this subtree represents something that can be
...@@ -696,6 +815,18 @@ class SemanticsProperties extends DiagnosticableTree { ...@@ -696,6 +815,18 @@ class SemanticsProperties extends DiagnosticableTree {
/// * [FocusNode], [FocusScope], [FocusManager], which manage the input focus /// * [FocusNode], [FocusScope], [FocusManager], which manage the input focus
final VoidCallback onDidLoseAccessibilityFocus; final VoidCallback onDidLoseAccessibilityFocus;
/// A map from each supported [CustomSemanticsAction] to a provided handler.
///
/// The handler associated with each custom action is called whenever a
/// semantics event of type [SemanticsEvent.customEvent] is received. The
/// provided argument will be an identifier used to retrieve an instance of
/// a custom action which can then retrieve the correct handler from this map.
///
/// See also:
///
/// * [CustomSemanticsAction], for an explanation of custom actions.
final Map<CustomSemanticsAction, VoidCallback> customSemanticsActions;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
...@@ -1103,6 +1234,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1103,6 +1234,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
// TAGS, LABELS, ACTIONS // TAGS, LABELS, ACTIONS
Map<SemanticsAction, _SemanticsActionHandler> _actions = _kEmptyConfig._actions; Map<SemanticsAction, _SemanticsActionHandler> _actions = _kEmptyConfig._actions;
Map<CustomSemanticsAction, VoidCallback> _customSemanticsActions = _kEmptyConfig._customSemanticsActions;
int _actionsAsBits = _kEmptyConfig._actionsAsBits; int _actionsAsBits = _kEmptyConfig._actionsAsBits;
...@@ -1242,6 +1374,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1242,6 +1374,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
_textDirection = config.textDirection; _textDirection = config.textDirection;
_sortKey = config.sortKey; _sortKey = config.sortKey;
_actions = new Map<SemanticsAction, _SemanticsActionHandler>.from(config._actions); _actions = new Map<SemanticsAction, _SemanticsActionHandler>.from(config._actions);
_customSemanticsActions = new Map<CustomSemanticsAction, VoidCallback>.from(config._customSemanticsActions);
_actionsAsBits = config._actionsAsBits; _actionsAsBits = config._actionsAsBits;
_textSelection = config._textSelection; _textSelection = config._textSelection;
_scrollPosition = config._scrollPosition; _scrollPosition = config._scrollPosition;
...@@ -1280,6 +1413,9 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1280,6 +1413,9 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
double scrollPosition = _scrollPosition; double scrollPosition = _scrollPosition;
double scrollExtentMax = _scrollExtentMax; double scrollExtentMax = _scrollExtentMax;
double scrollExtentMin = _scrollExtentMin; double scrollExtentMin = _scrollExtentMin;
final Set<int> customSemanticsActionIds = new Set<int>();
for (CustomSemanticsAction action in _customSemanticsActions.keys)
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
if (mergeAllDescendantsIntoThisNode) { if (mergeAllDescendantsIntoThisNode) {
_visitDescendants((SemanticsNode node) { _visitDescendants((SemanticsNode node) {
...@@ -1301,6 +1437,10 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1301,6 +1437,10 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
mergedTags ??= new Set<SemanticsTag>(); mergedTags ??= new Set<SemanticsTag>();
mergedTags.addAll(node.tags); mergedTags.addAll(node.tags);
} }
if (node._customSemanticsActions != null) {
for (CustomSemanticsAction action in _customSemanticsActions.keys)
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
}
label = _concatStrings( label = _concatStrings(
thisString: label, thisString: label,
thisTextDirection: textDirection, thisTextDirection: textDirection,
...@@ -1333,6 +1473,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1333,6 +1473,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
scrollPosition: scrollPosition, scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax, scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin, scrollExtentMin: scrollExtentMin,
customSemanticsActionIds: customSemanticsActionIds.toList()..sort(),
); );
} }
...@@ -1341,9 +1482,10 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1341,9 +1482,10 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
} }
static final Int32List _kEmptyChildList = new Int32List(0); static final Int32List _kEmptyChildList = new Int32List(0);
static final Int32List _kEmptyCustomSemanticsActionsList = new Int32List(0);
static final Float64List _kIdentityTransform = _initIdentityTransform(); static final Float64List _kIdentityTransform = _initIdentityTransform();
void _addToUpdate(ui.SemanticsUpdateBuilder builder) { void _addToUpdate(ui.SemanticsUpdateBuilder builder, Set<int> customSemanticsActionIdsUpdate) {
assert(_dirty); assert(_dirty);
final SemanticsData data = getSemanticsData(); final SemanticsData data = getSemanticsData();
Int32List childrenInTraversalOrder; Int32List childrenInTraversalOrder;
...@@ -1365,6 +1507,14 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1365,6 +1507,14 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
childrenInHitTestOrder[i] = _children[childCount - i - 1].id; childrenInHitTestOrder[i] = _children[childCount - i - 1].id;
} }
} }
Int32List customSemanticsActionIds;
if (data.customSemanticsActionIds?.isNotEmpty == true) {
customSemanticsActionIds = new Int32List(data.customSemanticsActionIds.length);
for (int i = 0; i < data.customSemanticsActionIds.length; i++) {
customSemanticsActionIds[i] = data.customSemanticsActionIds[i];
customSemanticsActionIdsUpdate.add(data.customSemanticsActionIds[i]);
}
}
builder.updateNode( builder.updateNode(
id: id, id: id,
flags: data.flags, flags: data.flags,
...@@ -1384,6 +1534,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1384,6 +1534,7 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
transform: data.transform?.storage ?? _kIdentityTransform, transform: data.transform?.storage ?? _kIdentityTransform,
childrenInTraversalOrder: childrenInTraversalOrder, childrenInTraversalOrder: childrenInTraversalOrder,
childrenInHitTestOrder: childrenInHitTestOrder, childrenInHitTestOrder: childrenInHitTestOrder,
customAcccessibilityActions: customSemanticsActionIds ?? _kEmptyCustomSemanticsActionsList,
); );
_dirty = false; _dirty = false;
} }
...@@ -1495,7 +1646,11 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { ...@@ -1495,7 +1646,11 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin {
properties.add(new DiagnosticsProperty<Rect>('rect', rect, description: description, showName: false)); properties.add(new DiagnosticsProperty<Rect>('rect', rect, description: description, showName: false));
} }
final List<String> actions = _actions.keys.map((SemanticsAction action) => describeEnum(action)).toList()..sort(); final List<String> actions = _actions.keys.map((SemanticsAction action) => describeEnum(action)).toList()..sort();
final List<String> customSemanticsActions = _customSemanticsActions.keys
.map<String>((CustomSemanticsAction action) => action.label)
.toList();
properties.add(new IterableProperty<String>('actions', actions, ifEmpty: null)); properties.add(new IterableProperty<String>('actions', actions, ifEmpty: null));
properties.add(new IterableProperty<String>('customActions', customSemanticsActions, ifEmpty: null));
final List<String> flags = SemanticsFlag.values.values.where((SemanticsFlag flag) => _hasFlag(flag)).map((SemanticsFlag flag) => flag.toString().substring('SemanticsFlag.'.length)).toList(); final List<String> flags = SemanticsFlag.values.values.where((SemanticsFlag flag) => _hasFlag(flag)).map((SemanticsFlag flag) => flag.toString().substring('SemanticsFlag.'.length)).toList();
properties.add(new IterableProperty<String>('flags', flags, ifEmpty: null)); properties.add(new IterableProperty<String>('flags', flags, ifEmpty: null));
properties.add(new FlagProperty('isInvisible', value: isInvisible, ifTrue: 'invisible')); properties.add(new FlagProperty('isInvisible', value: isInvisible, ifTrue: 'invisible'));
...@@ -1876,6 +2031,7 @@ class SemanticsOwner extends ChangeNotifier { ...@@ -1876,6 +2031,7 @@ class SemanticsOwner extends ChangeNotifier {
final Set<SemanticsNode> _dirtyNodes = new Set<SemanticsNode>(); final Set<SemanticsNode> _dirtyNodes = new Set<SemanticsNode>();
final Map<int, SemanticsNode> _nodes = <int, SemanticsNode>{}; final Map<int, SemanticsNode> _nodes = <int, SemanticsNode>{};
final Set<SemanticsNode> _detachedNodes = new Set<SemanticsNode>(); final Set<SemanticsNode> _detachedNodes = new Set<SemanticsNode>();
final Map<int, CustomSemanticsAction> _actions = <int, CustomSemanticsAction>{};
/// The root node of the semantics tree, if any. /// The root node of the semantics tree, if any.
/// ///
...@@ -1894,6 +2050,7 @@ class SemanticsOwner extends ChangeNotifier { ...@@ -1894,6 +2050,7 @@ class SemanticsOwner extends ChangeNotifier {
void sendSemanticsUpdate() { void sendSemanticsUpdate() {
if (_dirtyNodes.isEmpty) if (_dirtyNodes.isEmpty)
return; return;
final Set<int> customSemanticsActionIds = new Set<int>();
final List<SemanticsNode> visitedNodes = <SemanticsNode>[]; final List<SemanticsNode> visitedNodes = <SemanticsNode>[];
while (_dirtyNodes.isNotEmpty) { while (_dirtyNodes.isNotEmpty) {
final List<SemanticsNode> localDirtyNodes = _dirtyNodes.where((SemanticsNode node) => !_detachedNodes.contains(node)).toList(); final List<SemanticsNode> localDirtyNodes = _dirtyNodes.where((SemanticsNode node) => !_detachedNodes.contains(node)).toList();
...@@ -1927,9 +2084,11 @@ class SemanticsOwner extends ChangeNotifier { ...@@ -1927,9 +2084,11 @@ class SemanticsOwner extends ChangeNotifier {
// which happens e.g. when the node is no longer contributing // which happens e.g. when the node is no longer contributing
// semantics). // semantics).
if (node._dirty && node.attached) if (node._dirty && node.attached)
node._addToUpdate(builder); node._addToUpdate(builder, customSemanticsActionIds);
} }
_dirtyNodes.clear(); _dirtyNodes.clear();
for (int actionId in customSemanticsActionIds)
builder.updateCustomAction(id: actionId, label: CustomSemanticsAction.getAction(actionId).label);
ui.window.updateSemantics(builder.build()); ui.window.updateSemantics(builder.build());
notifyListeners(); notifyListeners();
} }
...@@ -2484,6 +2643,30 @@ class SemanticsConfiguration { ...@@ -2484,6 +2643,30 @@ class SemanticsConfiguration {
_hasBeenAnnotated = true; _hasBeenAnnotated = true;
} }
/// The handlers for each supported [CustomSemanticsAction].
///
/// Whenever a custom accessibility action is added to a node, the action
/// [SemanticAction.customAction] is automatically added. A handler is
/// created which uses the passed argument to lookup the custom action
/// handler from this map and invoke it, if present.
Map<CustomSemanticsAction, VoidCallback> get customSemanticsActions => _customSemanticsActions;
Map<CustomSemanticsAction, VoidCallback> _customSemanticsActions = <CustomSemanticsAction, VoidCallback>{};
set customSemanticsActions(Map<CustomSemanticsAction, VoidCallback> value) {
_hasBeenAnnotated = true;
_actionsAsBits |= SemanticsAction.customAction.index;
_customSemanticsActions = value;
_actions[SemanticsAction.customAction] = _onCustomSemanticsAction;
}
void _onCustomSemanticsAction(dynamic args) {
final CustomSemanticsAction action = CustomSemanticsAction.getAction(args);
if (action == null)
return;
final VoidCallback callback = _customSemanticsActions[action];
if (callback != null)
callback();
}
/// A textual description of the owning [RenderObject]. /// A textual description of the owning [RenderObject].
/// ///
/// On iOS this is used for the `accessibilityLabel` property defined in the /// On iOS this is used for the `accessibilityLabel` property defined in the
...@@ -2839,6 +3022,7 @@ class SemanticsConfiguration { ...@@ -2839,6 +3022,7 @@ class SemanticsConfiguration {
return; return;
_actions.addAll(other._actions); _actions.addAll(other._actions);
_customSemanticsActions.addAll(other._customSemanticsActions);
_actionsAsBits |= other._actionsAsBits; _actionsAsBits |= other._actionsAsBits;
_flags |= other._flags; _flags |= other._flags;
_textSelection ??= other._textSelection; _textSelection ??= other._textSelection;
...@@ -2892,7 +3076,8 @@ class SemanticsConfiguration { ...@@ -2892,7 +3076,8 @@ class SemanticsConfiguration {
.._scrollExtentMax = _scrollExtentMax .._scrollExtentMax = _scrollExtentMax
.._scrollExtentMin = _scrollExtentMin .._scrollExtentMin = _scrollExtentMin
.._actionsAsBits = _actionsAsBits .._actionsAsBits = _actionsAsBits
.._actions.addAll(_actions); .._actions.addAll(_actions)
.._customSemanticsActions.addAll(_customSemanticsActions);
} }
} }
......
...@@ -5089,6 +5089,7 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -5089,6 +5089,7 @@ class Semantics extends SingleChildRenderObjectWidget {
SetSelectionHandler onSetSelection, SetSelectionHandler onSetSelection,
VoidCallback onDidGainAccessibilityFocus, VoidCallback onDidGainAccessibilityFocus,
VoidCallback onDidLoseAccessibilityFocus, VoidCallback onDidLoseAccessibilityFocus,
Map<CustomSemanticsAction, VoidCallback> customSemanticsActions,
}) : this.fromProperties( }) : this.fromProperties(
key: key, key: key,
child: child, child: child,
...@@ -5129,6 +5130,7 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -5129,6 +5130,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter, onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter,
onDidGainAccessibilityFocus: onDidGainAccessibilityFocus, onDidGainAccessibilityFocus: onDidGainAccessibilityFocus,
onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus, onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus,
customSemanticsActions: customSemanticsActions,
onSetSelection: onSetSelection,), onSetSelection: onSetSelection,),
); );
...@@ -5216,6 +5218,7 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -5216,6 +5218,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onSetSelection: properties.onSetSelection, onSetSelection: properties.onSetSelection,
onDidGainAccessibilityFocus: properties.onDidGainAccessibilityFocus, onDidGainAccessibilityFocus: properties.onDidGainAccessibilityFocus,
onDidLoseAccessibilityFocus: properties.onDidLoseAccessibilityFocus, onDidLoseAccessibilityFocus: properties.onDidLoseAccessibilityFocus,
customSemanticsActions: properties.customSemanticsActions,
); );
} }
...@@ -5270,7 +5273,8 @@ class Semantics extends SingleChildRenderObjectWidget { ...@@ -5270,7 +5273,8 @@ class Semantics extends SingleChildRenderObjectWidget {
..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter ..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter
..onSetSelection = properties.onSetSelection ..onSetSelection = properties.onSetSelection
..onDidGainAccessibilityFocus = properties.onDidGainAccessibilityFocus ..onDidGainAccessibilityFocus = properties.onDidGainAccessibilityFocus
..onDidLoseAccessibilityFocus = properties.onDidLoseAccessibilityFocus; ..onDidLoseAccessibilityFocus = properties.onDidLoseAccessibilityFocus
..customSemanticsActions = properties.customSemanticsActions;
} }
@override @override
......
import 'package:test/test.dart';
import 'package:flutter/semantics.dart';
void main() {
group(CustomSemanticsAction, () {
test('is provided a canonical id based on the label', () {
final CustomSemanticsAction action1 = new CustomSemanticsAction(label: _nonconst('test'));
final CustomSemanticsAction action2 = new CustomSemanticsAction(label: _nonconst('test'));
final CustomSemanticsAction action3 = new CustomSemanticsAction(label: _nonconst('not test'));
final int id1 = CustomSemanticsAction.getIdentifier(action1);
final int id2 = CustomSemanticsAction.getIdentifier(action2);
final int id3 = CustomSemanticsAction.getIdentifier(action3);
expect(id1, id2);
expect(id2, isNot(id3));
expect(CustomSemanticsAction.getAction(id1), action1);
expect(CustomSemanticsAction.getAction(id2), action1);
expect(CustomSemanticsAction.getAction(id3), action3);
});
});
}
T _nonconst<T>(T value) => value;
...@@ -337,6 +337,7 @@ void main() { ...@@ -337,6 +337,7 @@ void main() {
' mergeAllDescendantsIntoThisNode: false\n' ' mergeAllDescendantsIntoThisNode: false\n'
' Rect.fromLTRB(0.0, 0.0, 0.0, 0.0)\n' ' Rect.fromLTRB(0.0, 0.0, 0.0, 0.0)\n'
' actions: []\n' ' actions: []\n'
' customActions: []\n'
' flags: []\n' ' flags: []\n'
' invisible\n' ' invisible\n'
' isHidden: false\n' ' isHidden: false\n'
...@@ -404,8 +405,49 @@ void main() { ...@@ -404,8 +405,49 @@ void main() {
); );
}); });
test('Custom actions debug properties', () {
final SemanticsConfiguration configuration = new SemanticsConfiguration();
const CustomSemanticsAction action1 = const CustomSemanticsAction(label: 'action1');
const CustomSemanticsAction action2 = const CustomSemanticsAction(label: 'action2');
const CustomSemanticsAction action3 = const CustomSemanticsAction(label: 'action3');
configuration.customSemanticsActions = <CustomSemanticsAction, VoidCallback>{
action1: () {},
action2: () {},
action3: () {},
};
final SemanticsNode actionNode = new SemanticsNode();
actionNode.updateWith(config: configuration);
expect(
actionNode.toStringDeep(minLevel: DiagnosticLevel.hidden),
'SemanticsNode#1\n'
' STALE\n'
' owner: null\n'
' isMergedIntoParent: false\n'
' mergeAllDescendantsIntoThisNode: false\n'
' Rect.fromLTRB(0.0, 0.0, 0.0, 0.0)\n'
' actions: customAction\n'
' customActions: action1, action2, action3\n'
' flags: []\n'
' invisible\n'
' isHidden: false\n'
' label: ""\n'
' value: ""\n'
' increasedValue: ""\n'
' decreasedValue: ""\n'
' hint: ""\n'
' textDirection: null\n'
' sortKey: null\n'
' scrollExtentMin: null\n'
' scrollPosition: null\n'
' scrollExtentMax: null\n'
);
});
test('SemanticsConfiguration getter/setter', () { test('SemanticsConfiguration getter/setter', () {
final SemanticsConfiguration config = new SemanticsConfiguration(); final SemanticsConfiguration config = new SemanticsConfiguration();
const CustomSemanticsAction customAction = const CustomSemanticsAction(label: 'test');
expect(config.isSemanticBoundary, isFalse); expect(config.isSemanticBoundary, isFalse);
expect(config.isButton, isFalse); expect(config.isButton, isFalse);
...@@ -428,6 +470,7 @@ void main() { ...@@ -428,6 +470,7 @@ void main() {
expect(config.onMoveCursorForwardByCharacter, isNull); expect(config.onMoveCursorForwardByCharacter, isNull);
expect(config.onMoveCursorBackwardByCharacter, isNull); expect(config.onMoveCursorBackwardByCharacter, isNull);
expect(config.onTap, isNull); expect(config.onTap, isNull);
expect(config.customSemanticsActions[customAction], isNull);
config.isSemanticBoundary = true; config.isSemanticBoundary = true;
config.isButton = true; config.isButton = true;
...@@ -450,6 +493,7 @@ void main() { ...@@ -450,6 +493,7 @@ void main() {
final MoveCursorHandler onMoveCursorForwardByCharacter = (bool _) { }; final MoveCursorHandler onMoveCursorForwardByCharacter = (bool _) { };
final MoveCursorHandler onMoveCursorBackwardByCharacter = (bool _) { }; final MoveCursorHandler onMoveCursorBackwardByCharacter = (bool _) { };
final VoidCallback onTap = () { }; final VoidCallback onTap = () { };
final VoidCallback onCustomAction = () {};
config.onShowOnScreen = onShowOnScreen; config.onShowOnScreen = onShowOnScreen;
config.onScrollDown = onScrollDown; config.onScrollDown = onScrollDown;
...@@ -462,6 +506,7 @@ void main() { ...@@ -462,6 +506,7 @@ void main() {
config.onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter; config.onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter;
config.onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter; config.onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter;
config.onTap = onTap; config.onTap = onTap;
config.customSemanticsActions[customAction] = onCustomAction;
expect(config.isSemanticBoundary, isTrue); expect(config.isSemanticBoundary, isTrue);
expect(config.isButton, isTrue); expect(config.isButton, isTrue);
...@@ -484,6 +529,7 @@ void main() { ...@@ -484,6 +529,7 @@ void main() {
expect(config.onMoveCursorForwardByCharacter, same(onMoveCursorForwardByCharacter)); expect(config.onMoveCursorForwardByCharacter, same(onMoveCursorForwardByCharacter));
expect(config.onMoveCursorBackwardByCharacter, same(onMoveCursorBackwardByCharacter)); expect(config.onMoveCursorBackwardByCharacter, same(onMoveCursorBackwardByCharacter));
expect(config.onTap, same(onTap)); expect(config.onTap, same(onTap));
expect(config.customSemanticsActions[customAction], same(onCustomAction));
}); });
} }
......
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