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);
return new Dismissible(
key: new ObjectKey(item),
direction: _dismissDirection,
onDismissed: (DismissDirection direction) {
setState(() { setState(() {
leaveBehindItems.remove(item); leaveBehindItems.remove(item);
}); });
final String action = (direction == DismissDirection.endToStart) ? 'archived' : 'deleted';
_scaffoldKey.currentState.showSnackBar(new SnackBar( _scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('You $action item ${item.index}'), content: new Text('You archived item ${item.index}'),
action: new SnackBarAction( action: new SnackBarAction(
label: 'UNDO', label: 'UNDO',
onPressed: () { handleUndo(item); } onPressed: () { handleUndo(item); }
) )
)); ));
}, }
background: new Container(
color: theme.primaryColor, void _handleDelete(LeaveBehindItem item) {
child: const ListTile( setState(() {
leading: const Icon(Icons.delete, color: Colors.white, size: 36.0) leaveBehindItems.remove(item);
) });
), _scaffoldKey.currentState.showSnackBar(new SnackBar(
secondaryBackground: new Container( content: new Text('You deleted item ${item.index}'),
color: theme.primaryColor, action: new SnackBarAction(
child: const ListTile( label: 'UNDO',
trailing: const Icon(Icons.archive, color: Colors.white, size: 36.0) onPressed: () { handleUndo(item); }
)
),
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
)
) )
); ));
} }
@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,15 +167,73 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> { ...@@ -163,15 +167,73 @@ 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({
Key key,
@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)
) )
: new ListView( ),
children: leaveBehindItems.map(buildItem).toList() 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() {
......
...@@ -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