Make UserAccountsDrawerHeader accessible (#13851)

Fixes #13743 
Fixes #12379
Follow-up to #13745

Also adds an option to hide gestures introduced by `InkWell` and `InkResponse` from the semantics tree (see also `GestureDetector.excludeFromSemantics`).
This commit is contained in:
Michael Goderbauer 2018-01-02 16:28:31 -08:00 committed by GitHub
parent f4040455d1
commit 94f48c2cc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 542 additions and 126 deletions

View file

@ -100,17 +100,33 @@ class _DrawerDemoState extends State<DrawerDemo> with TickerProviderStateMixin {
package: _kGalleryAssetsPackage,
),
),
otherAccountsPictures: const <Widget>[
const CircleAvatar(
backgroundImage: const AssetImage(
_kAsset1,
package: _kGalleryAssetsPackage,
otherAccountsPictures: <Widget>[
new GestureDetector(
onTap: () {
_onOtherAccountsTap(context);
},
child: new Semantics(
label: 'Switch to Account B',
child: const CircleAvatar(
backgroundImage: const AssetImage(
_kAsset1,
package: _kGalleryAssetsPackage,
),
),
),
),
const CircleAvatar(
backgroundImage: const AssetImage(
_kAsset2,
package: _kGalleryAssetsPackage,
new GestureDetector(
onTap: () {
_onOtherAccountsTap(context);
},
child: new Semantics(
label: 'Switch to Account C',
child: const CircleAvatar(
backgroundImage: const AssetImage(
_kAsset2,
package: _kGalleryAssetsPackage,
),
),
),
),
],
@ -213,4 +229,21 @@ class _DrawerDemoState extends State<DrawerDemo> with TickerProviderStateMixin {
),
);
}
void _onOtherAccountsTap(BuildContext context) {
showDialog<Null>(
context: context,
child: new AlertDialog(
title: const Text('Account switching not implemented.'),
actions: <Widget>[
new FlatButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
);
}
}

View file

@ -96,6 +96,7 @@ class InkResponse extends StatefulWidget {
this.highlightColor,
this.splashColor,
this.enableFeedback: true,
this.excludeFromSemantics: false,
}) : assert(enableFeedback != null), super(key: key);
/// The widget below this widget in the tree.
@ -194,6 +195,15 @@ class InkResponse extends StatefulWidget {
/// * [Feedback] for providing platform-specific feedback to certain actions.
final bool enableFeedback;
/// Whether to exclude the gestures introduced by this widget from the
/// semantics tree.
///
/// For example, a long-press gesture for showing a tooltip is usually
/// excluded because the tooltip itself is included in the semantics
/// tree directly and so having a gesture to show it would result in
/// duplication of information.
final bool excludeFromSemantics;
/// The rectangle to use for the highlight effect and for clipping
/// the splash effects if [containedInkWell] is true.
///
@ -379,7 +389,8 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
behavior: HitTestBehavior.opaque,
child: widget.child
child: widget.child,
excludeFromSemantics: widget.excludeFromSemantics,
);
}
@ -427,6 +438,7 @@ class InkWell extends InkResponse {
Color splashColor,
BorderRadius borderRadius,
bool enableFeedback: true,
bool excludeFromSemantics: false,
}) : super(
key: key,
child: child,
@ -440,5 +452,6 @@ class InkWell extends InkResponse {
splashColor: splashColor,
borderRadius: borderRadius,
enableFeedback: enableFeedback,
excludeFromSemantics: excludeFromSemantics,
);
}

View file

@ -10,6 +10,7 @@ import 'debug.dart';
import 'drawer_header.dart';
import 'icons.dart';
import 'ink_well.dart';
import 'material_localizations.dart';
import 'theme.dart';
class _AccountPictures extends StatelessWidget {
@ -31,21 +32,27 @@ class _AccountPictures extends StatelessWidget {
end: 0.0,
child: new Row(
children: (otherAccountsPictures ?? <Widget>[]).take(3).map((Widget picture) {
return new Container(
margin: const EdgeInsetsDirectional.only(start: 16.0),
width: 40.0,
height: 40.0,
child: picture
return new Semantics(
explicitChildNodes: true,
child: new Container(
margin: const EdgeInsetsDirectional.only(start: 16.0),
width: 40.0,
height: 40.0,
child: picture
),
);
}).toList(),
),
),
new Positioned(
top: 0.0,
child: new SizedBox(
width: 72.0,
height: 72.0,
child: currentAccountPicture
child: new Semantics(
explicitChildNodes: true,
child: new SizedBox(
width: 72.0,
height: 72.0,
child: currentAccountPicture
),
),
),
],
@ -67,66 +74,170 @@ class _AccountDetails extends StatelessWidget {
final VoidCallback onTap;
final bool isOpen;
Widget addDropdownIcon(Widget line) {
final Widget icon = new Icon(
isOpen ? Icons.arrow_drop_up : Icons.arrow_drop_down,
color: Colors.white
);
return new Expanded(
child: new Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: line == null ? <Widget>[icon] : <Widget>[
new Expanded(child: line),
icon,
],
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final ThemeData theme = Theme.of(context);
Widget accountNameLine = accountName == null ? null : new DefaultTextStyle(
style: theme.primaryTextTheme.body2,
overflow: TextOverflow.ellipsis,
child: accountName,
);
Widget accountEmailLine = accountEmail == null ? null : new DefaultTextStyle(
style: theme.primaryTextTheme.body1,
overflow: TextOverflow.ellipsis,
child: accountEmail,
);
if (onTap != null) {
if (accountEmailLine != null)
accountEmailLine = addDropdownIcon(accountEmailLine);
else
accountNameLine = addDropdownIcon(accountNameLine);
final List<Widget> children = <Widget>[];
if (accountName != null) {
final Widget accountNameLine = new LayoutId(
id: _AccountDetailsLayout.accountName,
child: new Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: new DefaultTextStyle(
style: theme.primaryTextTheme.body2,
overflow: TextOverflow.ellipsis,
child: accountName,
),
),
);
children.add(accountNameLine);
}
Widget accountDetails;
if (accountEmailLine != null || accountNameLine != null) {
accountDetails = new Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: new Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: (accountEmailLine != null && accountNameLine != null)
? <Widget>[accountNameLine, accountEmailLine]
: <Widget>[accountNameLine ?? accountEmailLine]
if (accountEmail != null) {
final Widget accountEmailLine = new LayoutId(
id: _AccountDetailsLayout.accountEmail,
child: new Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: new DefaultTextStyle(
style: theme.primaryTextTheme.body1,
overflow: TextOverflow.ellipsis,
child: accountEmail,
),
),
);
children.add(accountEmailLine);
}
if (onTap != null) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final Widget dropDownIcon = new LayoutId(
id: _AccountDetailsLayout.dropdownIcon,
child: new Semantics(
container: true,
button: true,
onTap: onTap,
child: new SizedBox(
height: _kAccountDetailsHeight,
width: _kAccountDetailsHeight,
child: new Center(
child: new Icon(
isOpen ? Icons.arrow_drop_up : Icons.arrow_drop_down,
color: Colors.white,
semanticLabel: isOpen
? localizations.hideAccountsLabel
: localizations.showAccountsLabel,
),
),
),
),
);
children.add(dropDownIcon);
}
Widget accountDetails = new CustomMultiChildLayout(
delegate: new _AccountDetailsLayout(
textDirection: Directionality.of(context),
),
children: children,
);
if (onTap != null) {
accountDetails = new InkWell(
onTap: onTap,
child: accountDetails,
excludeFromSemantics: true,
);
}
if (onTap != null)
accountDetails = new InkWell(onTap: onTap, child: accountDetails);
return new SizedBox(
height: 56.0,
height: _kAccountDetailsHeight,
child: accountDetails,
);
}
}
const double _kAccountDetailsHeight = 56.0;
class _AccountDetailsLayout extends MultiChildLayoutDelegate {
_AccountDetailsLayout({ @required this.textDirection });
static final String accountName = 'accountName';
static final String accountEmail = 'accountEmail';
static final String dropdownIcon = 'dropdownIcon';
final TextDirection textDirection;
@override
void performLayout(Size size) {
Size iconSize;
if (hasChild(dropdownIcon)) {
// place the dropdown icon in bottom right (LTR) or bottom left (RTL)
iconSize = layoutChild(dropdownIcon, new BoxConstraints.loose(size));
positionChild(dropdownIcon, _offsetForIcon(size, iconSize));
}
final String bottomLine = hasChild(accountEmail) ? accountEmail : (hasChild(accountName) ? accountName : null);
if (bottomLine != null) {
final Size constraintSize = iconSize == null ? size : size - new Offset(iconSize.width, 0.0);
iconSize ??= const Size(_kAccountDetailsHeight, _kAccountDetailsHeight);
// place bottom line center at same height as icon center
final Size bottomLineSize = layoutChild(bottomLine, new BoxConstraints.loose(constraintSize));
final Offset bottomLineOffset = _offsetForBottomLine(size, iconSize, bottomLineSize);
positionChild(bottomLine, bottomLineOffset);
// place account name above account email
if (bottomLine == accountEmail && hasChild(accountName)) {
final Size nameSize = layoutChild(accountName, new BoxConstraints.loose(constraintSize));
positionChild(accountName, _offsetForName(size, nameSize, bottomLineOffset));
}
}
}
@override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => true;
Offset _offsetForIcon(Size size, Size iconSize) {
switch (textDirection) {
case TextDirection.ltr:
return new Offset(size.width - iconSize.width, size.height - iconSize.height);
case TextDirection.rtl:
return new Offset(0.0, size.height - iconSize.height);
}
assert(false, 'Unreachable');
return null;
}
Offset _offsetForBottomLine(Size size, Size iconSize, Size bottomLineSize) {
final double y = size.height - 0.5 * iconSize.height - 0.5 * bottomLineSize.height;
switch (textDirection) {
case TextDirection.ltr:
return new Offset(0.0, y);
case TextDirection.rtl:
return new Offset(size.width - bottomLineSize.width, y);
}
assert(false, 'Unreachable');
return null;
}
Offset _offsetForName(Size size, Size nameSize, Offset bottomLineOffset) {
final double y = bottomLineOffset.dy - nameSize.height;
switch (textDirection) {
case TextDirection.ltr:
return new Offset(0.0, y);
case TextDirection.rtl:
return new Offset(size.width - nameSize.width, y);
}
assert(false, 'Unreachable');
return null;
}
}
/// A material design [Drawer] header that identifies the app's user.
///
/// Requires one of its ancestors to be a [Material] widget.
@ -195,29 +306,37 @@ class _UserAccountsDrawerHeaderState extends State<UserAccountsDrawerHeader> {
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
return new DrawerHeader(
decoration: widget.decoration ?? new BoxDecoration(
color: Theme.of(context).primaryColor,
),
margin: widget.margin,
child: new SafeArea(
bottom: false,
child: new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
new Expanded(
child: new _AccountPictures(
currentAccountPicture: widget.currentAccountPicture,
otherAccountsPictures: widget.otherAccountsPictures,
)
),
new _AccountDetails(
accountName: widget.accountName,
accountEmail: widget.accountEmail,
isOpen: _isOpen,
onTap: widget.onDetailsPressed == null ? null : _handleDetailsPressed,
),
],
return new Semantics(
container: true,
label: MaterialLocalizations.of(context).signedInLabel,
child: new DrawerHeader(
decoration: widget.decoration ?? new BoxDecoration(
color: Theme.of(context).primaryColor,
),
margin: widget.margin,
padding: const EdgeInsetsDirectional.only(top: 16.0, start: 16.0),
child: new SafeArea(
bottom: false,
child: new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
new Expanded(
child: new Padding(
padding: const EdgeInsetsDirectional.only(end: 16.0),
child: new _AccountPictures(
currentAccountPicture: widget.currentAccountPicture,
otherAccountsPictures: widget.otherAccountsPictures,
),
)
),
new _AccountDetails(
accountName: widget.accountName,
accountEmail: widget.accountEmail,
isOpen: _isOpen,
onTap: widget.onDetailsPressed == null ? null : _handleDetailsPressed,
),
],
),
),
),
);

View file

@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
void main() {
@ -156,4 +157,33 @@ void main() {
await runTest(true);
await runTest(false);
});
testWidgets('excludeFromSemantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new InkWell(
onTap: () { },
child: const Text('Button'),
),
),
));
expect(semantics, includesNodeWith(label: 'Button', actions: <SemanticsAction>[SemanticsAction.tap]));
await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new InkWell(
onTap: () { },
child: const Text('Button'),
excludeFromSemantics: true,
),
),
));
expect(semantics, isNot(includesNodeWith(label: 'Button', actions: <SemanticsAction>[SemanticsAction.tap])));
semantics.dispose();
});
}

View file

@ -2,59 +2,74 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show SemanticsFlags;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('UserAccountsDrawerHeader test', (WidgetTester tester) async {
final Key avatarA = const Key('A');
final Key avatarC = const Key('C');
final Key avatarD = const Key('D');
import '../widgets/semantics_tester.dart';
await tester.pumpWidget(
new MaterialApp(
home: new MediaQuery(
data: const MediaQueryData(
padding: const EdgeInsets.only(
left: 10.0,
top: 20.0,
right: 30.0,
bottom: 40.0,
),
const Key avatarA = const Key('A');
const Key avatarC = const Key('C');
const Key avatarD = const Key('D');
Future<Null> pumpTestWidget(WidgetTester tester, {
bool withName: true,
bool withEmail: true,
bool withOnDetailsPressedHandler: true,
}) async {
await tester.pumpWidget(
new MaterialApp(
home: new MediaQuery(
data: const MediaQueryData(
padding: const EdgeInsets.only(
left: 10.0,
top: 20.0,
right: 30.0,
bottom: 40.0,
),
child: new Material(
child: new Center(
child: new UserAccountsDrawerHeader(
currentAccountPicture: new CircleAvatar(
),
child: new Material(
child: new Center(
child: new UserAccountsDrawerHeader(
onDetailsPressed: withOnDetailsPressedHandler ? () {} : null,
currentAccountPicture: const ExcludeSemantics(
child: const CircleAvatar(
key: avatarA,
child: const Text('A'),
),
otherAccountsPictures: <Widget>[
const CircleAvatar(
child: const Text('B'),
),
new CircleAvatar(
key: avatarC,
child: const Text('C'),
),
new CircleAvatar(
key: avatarD,
child: const Text('D'),
),
const CircleAvatar(
child: const Text('E'),
)
],
accountName: const Text('name'),
accountEmail: const Text('email'),
),
otherAccountsPictures: <Widget>[
const CircleAvatar(
child: const Text('B'),
),
const CircleAvatar(
key: avatarC,
child: const Text('C'),
),
const CircleAvatar(
key: avatarD,
child: const Text('D'),
),
const CircleAvatar(
child: const Text('E'),
)
],
accountName: withName ? const Text('name') : null,
accountEmail: withEmail ? const Text('email') : null,
),
),
),
),
);
),
);
}
void main() {
testWidgets('UserAccountsDrawerHeader test', (WidgetTester tester) async {
await pumpTestWidget(tester);
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsOneWidget);
expect(find.text('C'), findsOneWidget);
@ -91,7 +106,7 @@ void main() {
});
testWidgets('UserAccountsDrawerHeader null parameters', (WidgetTester tester) async {
testWidgets('UserAccountsDrawerHeader null parameters LTR', (WidgetTester tester) async {
Widget buildFrame({
Widget currentAccountPicture,
List<Widget> otherAccountsPictures,
@ -134,6 +149,10 @@ void main() {
tester.getCenter(find.text('accountName')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountName')).dx,
lessThan(tester.getCenter(find.byType(Icon)).dx)
);
await tester.pumpWidget(buildFrame(
accountEmail: const Text('accountEmail'),
@ -143,6 +162,10 @@ void main() {
tester.getCenter(find.text('accountEmail')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountEmail')).dx,
lessThan(tester.getCenter(find.byType(Icon)).dx)
);
await tester.pumpWidget(buildFrame(
accountName: const Text('accountName'),
@ -153,6 +176,10 @@ void main() {
tester.getCenter(find.text('accountEmail')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountEmail')).dx,
lessThan(tester.getCenter(find.byType(Icon)).dx)
);
expect(
tester.getBottomLeft(find.text('accountEmail')).dy,
greaterThan(tester.getBottomLeft(find.text('accountName')).dy)
@ -186,4 +213,198 @@ void main() {
greaterThan(tester.getBottomLeft(find.byKey(avatarA)).dy)
);
});
testWidgets('UserAccountsDrawerHeader null parameters RTL', (WidgetTester tester) async {
Widget buildFrame({
Widget currentAccountPicture,
List<Widget> otherAccountsPictures,
Widget accountName,
Widget accountEmail,
VoidCallback onDetailsPressed,
EdgeInsets margin,
}) {
return new MaterialApp(
home: new Directionality(
textDirection: TextDirection.rtl,
child: new Material(
child: new Center(
child: new UserAccountsDrawerHeader(
currentAccountPicture: currentAccountPicture,
otherAccountsPictures: otherAccountsPictures,
accountName: accountName,
accountEmail: accountEmail,
onDetailsPressed: onDetailsPressed,
margin: margin,
),
),
),
),
);
}
await tester.pumpWidget(buildFrame());
final RenderBox box = tester.renderObject(find.byType(UserAccountsDrawerHeader));
expect(box.size.height, equals(160.0 + 1.0)); // height + bottom edge)
expect(find.byType(Icon), findsNothing);
await tester.pumpWidget(buildFrame(
onDetailsPressed: () { },
));
expect(find.byType(Icon), findsOneWidget);
await tester.pumpWidget(buildFrame(
accountName: const Text('accountName'),
onDetailsPressed: () { },
));
expect(
tester.getCenter(find.text('accountName')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountName')).dx,
greaterThan(tester.getCenter(find.byType(Icon)).dx)
);
await tester.pumpWidget(buildFrame(
accountEmail: const Text('accountEmail'),
onDetailsPressed: () { },
));
expect(
tester.getCenter(find.text('accountEmail')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountEmail')).dx,
greaterThan(tester.getCenter(find.byType(Icon)).dx)
);
await tester.pumpWidget(buildFrame(
accountName: const Text('accountName'),
accountEmail: const Text('accountEmail'),
onDetailsPressed: () { },
));
expect(
tester.getCenter(find.text('accountEmail')).dy,
tester.getCenter(find.byType(Icon)).dy
);
expect(
tester.getCenter(find.text('accountEmail')).dx,
greaterThan(tester.getCenter(find.byType(Icon)).dx)
);
expect(
tester.getBottomLeft(find.text('accountEmail')).dy,
greaterThan(tester.getBottomLeft(find.text('accountName')).dy)
);
expect(
tester.getBottomRight(find.text('accountEmail')).dx,
tester.getBottomRight(find.text('accountName')).dx
);
await tester.pumpWidget(buildFrame(
currentAccountPicture: const CircleAvatar(child: const Text('A')),
));
expect(find.text('A'), findsOneWidget);
await tester.pumpWidget(buildFrame(
otherAccountsPictures: <Widget>[const CircleAvatar(child: const Text('A'))],
));
expect(find.text('A'), findsOneWidget);
final Key avatarA = const Key('A');
await tester.pumpWidget(buildFrame(
currentAccountPicture: new CircleAvatar(key: avatarA, child: const Text('A')),
accountName: const Text('accountName'),
));
expect(
tester.getBottomRight(find.byKey(avatarA)).dx,
tester.getBottomRight(find.text('accountName')).dx
);
expect(
tester.getBottomLeft(find.text('accountName')).dy,
greaterThan(tester.getBottomLeft(find.byKey(avatarA)).dy)
);
});
testWidgets('UserAccountsDrawerHeader provides semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await pumpTestWidget(tester);
expect(
semantics,
hasSemantics(
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
label: 'Signed in\nname\nemail',
textDirection: TextDirection.ltr,
children: <TestSemantics>[
new TestSemantics(
label: r'B',
textDirection: TextDirection.ltr,
),
new TestSemantics(
label: r'C',
textDirection: TextDirection.ltr,
),
new TestSemantics(
label: r'D',
textDirection: TextDirection.ltr,
),
new TestSemantics(
flags: <SemanticsFlags>[SemanticsFlags.isButton],
actions: <SemanticsAction>[SemanticsAction.tap],
label: r'Show accounts',
textDirection: TextDirection.ltr,
),
],
),
],
),
ignoreId: true, ignoreTransform: true, ignoreRect: true,
),
);
semantics.dispose();
});
testWidgets('UserAccountsDrawerHeader provides semantics with missing properties', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await pumpTestWidget(
tester,
withEmail: false,
withName: false,
withOnDetailsPressedHandler: false,
);
expect(
semantics,
hasSemantics(
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
label: 'Signed in',
textDirection: TextDirection.ltr,
children: <TestSemantics>[
new TestSemantics(
label: r'B',
textDirection: TextDirection.ltr,
),
new TestSemantics(
label: r'C',
textDirection: TextDirection.ltr,
),
new TestSemantics(
label: r'D',
textDirection: TextDirection.ltr,
),
],
),
],
),
ignoreId: true, ignoreTransform: true, ignoreRect: true,
),
);
semantics.dispose();
});
}