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 @@
import 'package:collection/collection.dart' show lowerBound;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
enum LeaveBehindDemoAction {
reset,
......@@ -85,52 +86,55 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> {
});
}
Widget buildItem(LeaveBehindItem item) {
final ThemeData theme = Theme.of(context);
return new Dismissible(
key: new ObjectKey(item),
direction: _dismissDirection,
onDismissed: (DismissDirection direction) {
void _handleArchive(LeaveBehindItem item) {
setState(() {
leaveBehindItems.remove(item);
});
final String action = (direction == DismissDirection.endToStart) ? 'archived' : 'deleted';
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('You $action item ${item.index}'),
content: new Text('You archived 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
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(
key: _scaffoldKey,
appBar: new AppBar(
......@@ -163,15 +167,73 @@ class LeaveBehindDemoState extends State<LeaveBehindDemo> {
)
]
),
body: leaveBehindItems.isEmpty
? new Center(
child: new RaisedButton(
onPressed: () => handleDemoAction(LeaveBehindDemoAction.reset),
child: const Text('Reset the list'),
body: body,
);
}
}
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 {
SetSelectionHandler onSetSelection,
VoidCallback onDidGainAccessibilityFocus,
VoidCallback onDidLoseAccessibilityFocus,
Map<CustomSemanticsAction, VoidCallback> customSemanticsActions,
}) : assert(container != null),
_container = container,
_explicitChildNodes = explicitChildNodes,
......@@ -3197,6 +3198,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
_onSetSelection = onSetSelection,
_onDidGainAccessibilityFocus = onDidGainAccessibilityFocus,
_onDidLoseAccessibilityFocus = onDidLoseAccessibilityFocus,
_customSemanticsActions = customSemanticsActions,
super(child);
/// If 'container' is true, this [RenderObject] will introduce a new
......@@ -3779,6 +3781,24 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
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
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
......@@ -3860,6 +3880,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
config.onDidGainAccessibilityFocus = _performDidGainAccessibilityFocus;
if (onDidLoseAccessibilityFocus != null)
config.onDidLoseAccessibilityFocus = _performDidLoseAccessibilityFocus;
if (customSemanticsActions != null)
config.customSemanticsActions = _customSemanticsActions;
}
void _performTap() {
......
......@@ -5089,6 +5089,7 @@ class Semantics extends SingleChildRenderObjectWidget {
SetSelectionHandler onSetSelection,
VoidCallback onDidGainAccessibilityFocus,
VoidCallback onDidLoseAccessibilityFocus,
Map<CustomSemanticsAction, VoidCallback> customSemanticsActions,
}) : this.fromProperties(
key: key,
child: child,
......@@ -5129,6 +5130,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter,
onDidGainAccessibilityFocus: onDidGainAccessibilityFocus,
onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus,
customSemanticsActions: customSemanticsActions,
onSetSelection: onSetSelection,),
);
......@@ -5216,6 +5218,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onSetSelection: properties.onSetSelection,
onDidGainAccessibilityFocus: properties.onDidGainAccessibilityFocus,
onDidLoseAccessibilityFocus: properties.onDidLoseAccessibilityFocus,
customSemanticsActions: properties.customSemanticsActions,
);
}
......@@ -5270,7 +5273,8 @@ class Semantics extends SingleChildRenderObjectWidget {
..onMoveCursorBackwardByCharacter = properties.onMoveCursorForwardByCharacter
..onSetSelection = properties.onSetSelection
..onDidGainAccessibilityFocus = properties.onDidGainAccessibilityFocus
..onDidLoseAccessibilityFocus = properties.onDidLoseAccessibilityFocus;
..onDidLoseAccessibilityFocus = properties.onDidLoseAccessibilityFocus
..customSemanticsActions = properties.customSemanticsActions;
}
@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() {
' mergeAllDescendantsIntoThisNode: false\n'
' Rect.fromLTRB(0.0, 0.0, 0.0, 0.0)\n'
' actions: []\n'
' customActions: []\n'
' flags: []\n'
' invisible\n'
' isHidden: false\n'
......@@ -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', () {
final SemanticsConfiguration config = new SemanticsConfiguration();
const CustomSemanticsAction customAction = const CustomSemanticsAction(label: 'test');
expect(config.isSemanticBoundary, isFalse);
expect(config.isButton, isFalse);
......@@ -428,6 +470,7 @@ void main() {
expect(config.onMoveCursorForwardByCharacter, isNull);
expect(config.onMoveCursorBackwardByCharacter, isNull);
expect(config.onTap, isNull);
expect(config.customSemanticsActions[customAction], isNull);
config.isSemanticBoundary = true;
config.isButton = true;
......@@ -450,6 +493,7 @@ void main() {
final MoveCursorHandler onMoveCursorForwardByCharacter = (bool _) { };
final MoveCursorHandler onMoveCursorBackwardByCharacter = (bool _) { };
final VoidCallback onTap = () { };
final VoidCallback onCustomAction = () {};
config.onShowOnScreen = onShowOnScreen;
config.onScrollDown = onScrollDown;
......@@ -462,6 +506,7 @@ void main() {
config.onMoveCursorForwardByCharacter = onMoveCursorForwardByCharacter;
config.onMoveCursorBackwardByCharacter = onMoveCursorBackwardByCharacter;
config.onTap = onTap;
config.customSemanticsActions[customAction] = onCustomAction;
expect(config.isSemanticBoundary, isTrue);
expect(config.isButton, isTrue);
......@@ -484,6 +529,7 @@ void main() {
expect(config.onMoveCursorForwardByCharacter, same(onMoveCursorForwardByCharacter));
expect(config.onMoveCursorBackwardByCharacter, same(onMoveCursorBackwardByCharacter));
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