Commit c608e666 authored by 5u3it's avatar 5u3it Committed by Adam Barth

Material scaffold to have simultaneous left-and-right drawers (#12686)

Adds `Scaffold#endDrawer` property to supply a second drawer to a Scaffold.
parent 05e10633
......@@ -488,4 +488,4 @@
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
}
\ No newline at end of file
......@@ -113,4 +113,4 @@
"version" : 1,
"author" : "xcode"
}
}
}
\ No newline at end of file
......@@ -325,6 +325,10 @@ class _AppBarState extends State<AppBar> {
Scaffold.of(context).openDrawer();
}
void _handleDrawerButtonEnd() {
Scaffold.of(context).openEndDrawer();
}
@override
Widget build(BuildContext context) {
assert(!widget.primary || debugCheckHasMediaQuery(context));
......@@ -333,6 +337,7 @@ class _AppBarState extends State<AppBar> {
final ModalRoute<dynamic> parentRoute = ModalRoute.of(context);
final bool hasDrawer = scaffold?.hasDrawer ?? false;
final bool hasEndDrawer = scaffold?.hasEndDrawer ?? false;
final bool canPop = parentRoute?.canPop ?? false;
final bool useCloseButton = parentRoute is MaterialPageRoute<dynamic> && parentRoute.fullscreenDialog;
......@@ -393,6 +398,12 @@ class _AppBarState extends State<AppBar> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.actions,
);
} else if (hasEndDrawer) {
actions = new IconButton(
icon: const Icon(Icons.menu),
onPressed: _handleDrawerButtonEnd,
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
);
}
final Widget toolbar = new Padding(
......
......@@ -9,6 +9,21 @@ import 'colors.dart';
import 'list_tile.dart';
import 'material.dart';
/// The alginment of a [Drawer] which is used to identify positioning
/// of the [Drawer]
///
enum DrawerAlignment {
/// Denotes that the [Drawer] is at the start side of the [Scaffold]
/// i.e. left side when Directionality is LTR
/// and right side when Directionality is RTL
start,
/// Denotes that the [Drawer] is at the end side of the [Scaffold]
/// i.e. right side when Directionality is LTR
/// and left side when Directionality is RTL
end,
}
// TODO(eseidel): Draw width should vary based on device size:
// http://material.google.com/layout/structure.html#structure-side-nav
......@@ -118,7 +133,9 @@ class DrawerController extends StatefulWidget {
const DrawerController({
GlobalKey key,
@required this.child,
}) : assert(child != null),
@required this.alignment,
}) : assert(child != null),
assert(alignment != null),
super(key: key);
/// The widget below this widget in the tree.
......@@ -126,6 +143,11 @@ class DrawerController extends StatefulWidget {
/// Typically a [Drawer].
final Widget child;
/// The alginment of a [Drawer] which is used to identify positioning
/// of the [Drawer] i.e. either start-side or end-side
///
final DrawerAlignment alignment;
@override
DrawerControllerState createState() => new DrawerControllerState();
}
......@@ -217,7 +239,14 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
}
void _move(DragUpdateDetails details) {
final double delta = details.primaryDelta / _width;
double delta = details.primaryDelta / _width;
switch (widget.alignment) {
case DrawerAlignment.start:
break;
case DrawerAlignment.end:
delta = -delta;
break;
}
switch (Directionality.of(context)) {
case TextDirection.rtl:
_controller.value -= delta;
......@@ -232,7 +261,14 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
if (_controller.isDismissed)
return;
if (details.velocity.pixelsPerSecond.dx.abs() >= _kMinFlingVelocity) {
final double visualVelocity = details.velocity.pixelsPerSecond.dx / _width;
double visualVelocity = details.velocity.pixelsPerSecond.dx / _width;
switch (widget.alignment) {
case DrawerAlignment.start:
break;
case DrawerAlignment.end:
visualVelocity = -visualVelocity;
break;
}
switch (Directionality.of(context)) {
case TextDirection.rtl:
_controller.fling(velocity: -visualVelocity);
......@@ -240,8 +276,7 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
case TextDirection.ltr:
_controller.fling(velocity: visualVelocity);
break;
}
}
} else if (_controller.value < 0.5) {
close();
} else {
......@@ -264,10 +299,28 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
final ColorTween _color = new ColorTween(begin: Colors.transparent, end: Colors.black54);
final GlobalKey _gestureDetectorKey = new GlobalKey();
AlignmentDirectional get _drawerOuterAlignment {
switch (widget.alignment) {
case DrawerAlignment.start:
return AlignmentDirectional.centerStart;
case DrawerAlignment.end:
return AlignmentDirectional.centerEnd;
}
}
AlignmentDirectional get _drawerInnerAlignment {
switch (widget.alignment) {
case DrawerAlignment.start:
return AlignmentDirectional.centerEnd;
case DrawerAlignment.end:
return AlignmentDirectional.centerStart;
}
}
Widget _buildDrawer(BuildContext context) {
if (_controller.status == AnimationStatus.dismissed) {
return new Align(
alignment: AlignmentDirectional.centerStart,
alignment: _drawerOuterAlignment,
child: new GestureDetector(
key: _gestureDetectorKey,
onHorizontalDragUpdate: _move,
......@@ -299,9 +352,9 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
),
),
new Align(
alignment: AlignmentDirectional.centerStart,
alignment: _drawerOuterAlignment,
child: new Align(
alignment: AlignmentDirectional.centerEnd,
alignment: _drawerInnerAlignment,
widthFactor: _controller.value,
child: new RepaintBoundary(
child: new FocusScope(
......@@ -325,4 +378,4 @@ class DrawerControllerState extends State<DrawerController> with SingleTickerPro
child: _buildDrawer(context),
);
}
}
}
\ No newline at end of file
......@@ -32,6 +32,7 @@ enum _ScaffoldSlot {
bottomNavigationBar,
floatingActionButton,
drawer,
endDrawer,
statusBar,
}
......@@ -150,6 +151,11 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
layoutChild(_ScaffoldSlot.drawer, new BoxConstraints.tight(size));
positionChild(_ScaffoldSlot.drawer, Offset.zero);
}
if (hasChild(_ScaffoldSlot.endDrawer)) {
layoutChild(_ScaffoldSlot.endDrawer, new BoxConstraints.tight(size));
positionChild(_ScaffoldSlot.endDrawer, Offset.zero);
}
}
@override
......@@ -312,6 +318,7 @@ class Scaffold extends StatefulWidget {
this.floatingActionButton,
this.persistentFooterButtons,
this.drawer,
this.endDrawer,
this.bottomNavigationBar,
this.backgroundColor,
this.resizeToAvoidBottomPadding: true,
......@@ -360,14 +367,25 @@ class Scaffold extends StatefulWidget {
final List<Widget> persistentFooterButtons;
/// A panel displayed to the side of the [body], often hidden on mobile
/// devices.
///
/// devices. Swipes in from either left-to-right ([TextDirection.ltr]) or
/// right-to-left ([TextDirection.rtl])
///
/// In the uncommon case that you wish to open the drawer manually, use the
/// [ScaffoldState.openDrawer] function.
///
/// Typically a [Drawer].
final Widget drawer;
/// A panel displayed to the side of the [body], often hidden on mobile
/// devices. Swipes in from right-to-left ([TextDirection.ltr]) or
/// left-to-right ([TextDirection.rtl])
///
/// In the uncommon case that you wish to open the drawer manually, use the
/// [ScaffoldState.openDrawer] function.
///
/// Typically a [Drawer].
final Widget endDrawer;
/// The color of the [Material] widget that underlies the entire Scaffold.
///
/// The theme's [ThemeData.scaffoldBackgroundColor] by default.
......@@ -528,9 +546,12 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
// DRAWER API
final GlobalKey<DrawerControllerState> _drawerKey = new GlobalKey<DrawerControllerState>();
final GlobalKey<DrawerControllerState> _endDrawerKey = new GlobalKey<DrawerControllerState>();
/// Whether this scaffold has a non-null [Scaffold.drawer].
bool get hasDrawer => widget.drawer != null;
/// Whether this scaffold has a non-null [Scaffold.endDrawer].
bool get hasEndDrawer => widget.endDrawer != null;
/// Opens the [Drawer] (if any).
///
......@@ -548,6 +569,22 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
_drawerKey.currentState?.open();
}
/// Opens the end side [Drawer] (if any).
///
/// If the scaffold has a non-null [Scaffold.endDrawer], this function will cause
/// the end side drawer to begin its entrance animation.
///
/// Normally this is not needed since the [Scaffold] automatically shows an
/// appropriate [IconButton], and handles the edge-swipe gesture, to show the
/// drawer.
///
/// To close the end side drawer once it is open, use [Navigator.pop].
///
/// See [Scaffold.of] for information about how to obtain the [ScaffoldState].
void openEndDrawer() {
_endDrawerKey.currentState?.open();
}
// SNACKBAR API
final Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
......@@ -945,6 +982,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
children,
new DrawerController(
key: _drawerKey,
alignment: DrawerAlignment.start,
child: widget.drawer,
),
_ScaffoldSlot.drawer,
......@@ -956,6 +994,24 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
);
}
if (widget.endDrawer != null) {
assert(hasEndDrawer);
_addIfNonNull(
children,
new DrawerController(
key: _endDrawerKey,
alignment: DrawerAlignment.end,
child: widget.endDrawer,
),
_ScaffoldSlot.endDrawer,
// remove the side padding from the side we're not touching
removeLeftPadding: textDirection == TextDirection.ltr,
removeTopPadding: false,
removeRightPadding: textDirection == TextDirection.rtl,
removeBottomPadding: false,
);
}
double endPadding;
switch (textDirection) {
case TextDirection.rtl:
......@@ -1096,4 +1152,4 @@ class _ScaffoldScope extends InheritedWidget {
bool updateShouldNotify(_ScaffoldScope oldWidget) {
return hasDrawer != oldWidget.hasDrawer;
}
}
}
\ No newline at end of file
......@@ -623,4 +623,39 @@ void main() {
expect(tester.getRect(find.byKey(insideDrawer)), new Rect.fromLTRB(596.0, 30.0, 750.0, 530.0));
expect(tester.getRect(find.byKey(insideBottomNavigationBar)), new Rect.fromLTRB(20.0, 475.0, 750.0, 530.0));
});
testWidgets('Simultaneous drawers on either side', (WidgetTester tester) async {
const String bodyLabel = 'I am the body';
const String drawerLabel = 'I am the label on start side';
const String endDrawerLabel = 'I am the label on end side';
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(new MaterialApp(home: new Scaffold(
body: const Text(bodyLabel),
drawer: const Drawer(child:const Text(drawerLabel)),
endDrawer: const Drawer(child:const Text(endDrawerLabel)),
)));
expect(semantics, includesNodeWith(label: bodyLabel));
expect(semantics, isNot(includesNodeWith(label: drawerLabel)));
expect(semantics, isNot(includesNodeWith(label: endDrawerLabel)));
final ScaffoldState state = tester.firstState(find.byType(Scaffold));
state.openDrawer();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(semantics, isNot(includesNodeWith(label: bodyLabel)));
expect(semantics, includesNodeWith(label: drawerLabel));
state.openEndDrawer();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(semantics, isNot(includesNodeWith(label: bodyLabel)));
expect(semantics, includesNodeWith(label: endDrawerLabel));
semantics.dispose();
});
}
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