diff --git a/examples/material_gallery/flutter.yaml b/examples/material_gallery/flutter.yaml index ed1313ae00..ac531537ed 100644 --- a/examples/material_gallery/flutter.yaml +++ b/examples/material_gallery/flutter.yaml @@ -27,6 +27,8 @@ material-design-icons: - name: action/account_circle - name: action/alarm - name: action/android + - name: action/delete + - name: action/done - name: action/event - name: action/face - name: action/home @@ -58,3 +60,4 @@ material-design-icons: - name: navigation/menu - name: navigation/more_horiz - name: navigation/more_vert + - name: social/person_add diff --git a/examples/material_gallery/lib/demo/grid_list_demo.dart b/examples/material_gallery/lib/demo/grid_list_demo.dart index 0cdecf1d74..8068368c5e 100644 --- a/examples/material_gallery/lib/demo/grid_list_demo.dart +++ b/examples/material_gallery/lib/demo/grid_list_demo.dart @@ -129,6 +129,8 @@ class GridListDemoGridDelegate extends FixedColumnCountGridDelegate { } class GridListDemo extends StatefulComponent { + GridListDemo({ Key key }) : super(key: key); + GridListDemoState createState() => new GridListDemoState(); } diff --git a/examples/material_gallery/lib/demo/list_demo.dart b/examples/material_gallery/lib/demo/list_demo.dart index 593e69cdf4..924dd4695d 100644 --- a/examples/material_gallery/lib/demo/list_demo.dart +++ b/examples/material_gallery/lib/demo/list_demo.dart @@ -175,11 +175,9 @@ class ListDemoState extends State { ) ] ), - body: new Padding( - padding: const EdgeDims.all(8.0), - child: new Block( - children: items.map((String item) => buildListItem(context, item)).toList() - ) + body: new Block( + padding: new EdgeDims.all(_isDense ? 4.0 : 8.0), + children: items.map((String item) => buildListItem(context, item)).toList() ) ); } diff --git a/examples/material_gallery/lib/demo/menu_demo.dart b/examples/material_gallery/lib/demo/menu_demo.dart new file mode 100644 index 0000000000..98ab3794b2 --- /dev/null +++ b/examples/material_gallery/lib/demo/menu_demo.dart @@ -0,0 +1,214 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class MenuDemo extends StatefulComponent { + MenuDemo({ Key key }) : super(key: key); + + MenuDemoState createState() => new MenuDemoState(); +} + +class MenuDemoState extends State { + final GlobalKey _scaffoldKey = new GlobalKey(); + + final String _simpleValue1 = 'Menu item value one'; + final String _simpleValue2 = 'Menu item value two'; + final String _simpleValue3 = 'Menu item value three'; + String _simpleValue; + + final String _checkedValue1 = 'One'; + final String _checkedValue2 = 'Two'; + final String _checkedValue3 = 'Free'; + final String _checkedValue4 = 'Four'; + List _checkedValues; + + void initState() { + super.initState(); + _simpleValue = _simpleValue2; + _checkedValues = [_checkedValue3]; + } + + void showInSnackBar(String value) { + _scaffoldKey.currentState.showSnackBar(new SnackBar( + content: new Text(value) + )); + } + + void showMenuSelection(String value) { + if ([_simpleValue1, _simpleValue2, _simpleValue3].contains(value)) + _simpleValue = value; + showInSnackBar('You selected: $value'); + } + + void showCheckedMenuSelections(String value) { + if (_checkedValues.contains(value)) + _checkedValues.remove(value); + else + _checkedValues.add(value); + + showInSnackBar('Checked $_checkedValues'); + } + + bool isChecked(String value) => _checkedValues.contains(value); + + Widget build(BuildContext context) { + return new Scaffold( + key: _scaffoldKey, + toolBar: new ToolBar( + center: new Text('Menus'), + right: [ + new PopupMenuButton( + onSelected: showMenuSelection, + items: [ + new PopupMenuItem( + value: 'ToolBar Menu', + child: new Text('ToolBar Menu') + ), + new PopupMenuItem( + value: 'Right Here', + child: new Text('Right Here') + ), + new PopupMenuItem( + value: 'Hooray!', + child: new Text('Hooray!') + ), + ] + ) + ] + ), + body: new Block( + padding: const EdgeDims.all(8.0), + children: [ + // Pressing the PopupMenuButton on the right of this item shows + // a simple menu with one disabled item. Typically the contents + // of this "contextual menu" would reflect the app's state. + new ListItem( + primary: new Text('An item with a context menu button'), + right: new PopupMenuButton( + onSelected: showMenuSelection, + items: [ + new PopupMenuItem( + value: _simpleValue1, + child: new Text('Context menu item one') + ), + new PopupMenuItem( + disabled: true, + child: new Text('A disabled menu item') + ), + new PopupMenuItem( + value: _simpleValue3, + child: new Text('Context menu item three') + ), + ] + ) + ), + // Pressing the PopupMenuButton on the right of this item shows + // a menu whose items have text labels and icons and a divider + // That separates the first three items from the last one. + new ListItem( + primary: new Text('An item with a sectioned menu'), + right: new PopupMenuButton( + onSelected: showMenuSelection, + items: [ + new PopupMenuItem( + value: 'Preview', + child: new ListItem( + left: new Icon(icon: 'action/visibility'), + primary: new Text('Preview') + ) + ), + new PopupMenuItem( + value: 'Share', + child: new ListItem( + left: new Icon(icon: 'social/person_add'), + primary: new Text('Share') + ) + ), + new PopupMenuItem( + value: 'Get Link', + hasDivider: true, + child: new ListItem( + left: new Icon(icon: 'content/link'), + primary: new Text('Get Link') + ) + ), + new PopupMenuItem( + value: 'Remove', + child: new ListItem( + left: new Icon(icon: 'action/delete'), + primary: new Text('Remove') + ) + ) + ] + ) + ), + // This entire list item is a PopupMenuButton. Tapping anywhere shows + // a menu whose current value is highlighted and aligned over the + // list item's center line. + new PopupMenuButton( + initialValue: _simpleValue, + onSelected: showMenuSelection, + child: new ListItem( + primary: new Text('An item with a simple menu'), + secondary: new Text(_simpleValue) + ), + items: [ + new PopupMenuItem( + value: _simpleValue1, + child: new Text(_simpleValue1) + ), + new PopupMenuItem( + value: _simpleValue2, + child: new Text(_simpleValue2) + ), + new PopupMenuItem( + value: _simpleValue3, + child: new Text(_simpleValue3) + ) + ] + ), + // Pressing the PopupMenuButton on the right of this item shows a menu + // whose items have checked icons that reflect this app's state. + new ListItem( + primary: new Text('An item with a checklist menu'), + right: new PopupMenuButton( + onSelected: showCheckedMenuSelections, + items: [ + new PopupMenuItem( + value: _checkedValue1, + child: new ListItem( + left: new Icon(icon: isChecked(_checkedValue1) ? 'action/done' : null), + primary: new Text(_checkedValue1) + ) + ), + new PopupMenuItem( + value: _checkedValue2, + child: new ListItem( + left: new Icon(icon: isChecked(_checkedValue2) ? 'action/done' : null), + primary: new Text(_checkedValue2) + ) + ), + new PopupMenuItem( + value: _checkedValue3, + child: new ListItem( + left: new Icon(icon: isChecked(_checkedValue3) ? 'action/done' : null), + primary: new Text(_checkedValue3) + ) + ), + new PopupMenuItem( + value: _checkedValue4, + child: new ListItem( + left: new Icon(icon: isChecked(_checkedValue4) ? 'action/done' : null), + primary: new Text(_checkedValue4) + ) + ), + ] + ) + ) + ] + ) + ); + } +} diff --git a/examples/material_gallery/lib/gallery/home.dart b/examples/material_gallery/lib/gallery/home.dart index 2a015ee9c6..8fab48d6a4 100644 --- a/examples/material_gallery/lib/gallery/home.dart +++ b/examples/material_gallery/lib/gallery/home.dart @@ -21,6 +21,7 @@ import '../demo/grid_list_demo.dart'; import '../demo/icons_demo.dart'; import '../demo/list_demo.dart'; import '../demo/modal_bottom_sheet_demo.dart'; +import '../demo/menu_demo.dart'; import '../demo/page_selector_demo.dart'; import '../demo/persistent_bottom_sheet_demo.dart'; import '../demo/progress_indicator_demo.dart'; @@ -108,6 +109,7 @@ class GalleryHomeState extends State { new GalleryDemo(title: 'Icons', builder: () => new IconsDemo()), new GalleryDemo(title: 'List', builder: () => new ListDemo()), new GalleryDemo(title: 'Modal Bottom Sheet', builder: () => new ModalBottomSheetDemo()), + new GalleryDemo(title: 'Menus', builder: () => new MenuDemo()), new GalleryDemo(title: 'Page Selector', builder: () => new PageSelectorDemo()), new GalleryDemo(title: 'Persistent Bottom Sheet', builder: () => new PersistentBottomSheetDemo()), new GalleryDemo(title: 'Progress Indicators', builder: () => new ProgressIndicatorDemo()), diff --git a/packages/flutter/lib/src/material/icon.dart b/packages/flutter/lib/src/material/icon.dart index c7d5f2522d..3c8873ccf6 100644 --- a/packages/flutter/lib/src/material/icon.dart +++ b/packages/flutter/lib/src/material/icon.dart @@ -27,12 +27,11 @@ class Icon extends StatelessComponent { Icon({ Key key, this.size: IconSize.s24, - this.icon: '', + this.icon, this.colorTheme, this.color }) : super(key: key) { assert(size != null); - assert(icon != null); } final IconSize size; @@ -54,6 +53,14 @@ class Icon extends StatelessComponent { } Widget build(BuildContext context) { + final int iconSize = _kIconSize[size]; + if (icon == null) { + return new SizedBox( + width: iconSize.toDouble(), + height: iconSize.toDouble() + ); + } + String category = ''; String subtype = ''; List parts = icon.split('/'); @@ -62,7 +69,6 @@ class Icon extends StatelessComponent { subtype = parts[1]; } final IconThemeColor iconThemeColor = _getIconThemeColor(context); - final int iconSize = _kIconSize[size]; String colorSuffix; switch(iconThemeColor) { diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index 2fa5473c17..a99d2f8958 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'icon_button.dart'; import 'ink_well.dart'; import 'material.dart'; import 'theme.dart'; @@ -19,24 +20,37 @@ const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuVerticalPadding = 8.0; const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; class PopupMenuItem extends StatelessComponent { PopupMenuItem({ Key key, this.value, + this.disabled: false, + this.hasDivider: false, this.child }) : super(key: key); - final Widget child; final T value; + final bool disabled; + final bool hasDivider; + final Widget child; Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + TextStyle style = theme.text.subhead; + if (disabled) + style = style.copyWith(color: theme.disabledColor); + return new MergeSemantics( child: new Container( height: _kMenuItemHeight, padding: const EdgeDims.symmetric(horizontal: _kMenuHorizontalPadding), + decoration: !hasDivider ? null : new BoxDecoration( + border: new Border(bottom: new BorderSide(color: theme.dividerColor)) + ), child: new DefaultTextStyle( - style: Theme.of(context).text.subhead, + style: style, child: new Baseline( baseline: _kMenuItemHeight - _kBaselineOffsetFromBottom, child: child @@ -60,19 +74,27 @@ class _PopupMenu extends StatelessComponent { List children = []; for (int i = 0; i < route.items.length; ++i) { - double start = (i + 1) * unit; - double end = (start + 1.5 * unit).clamp(0.0, 1.0); + final double start = (i + 1) * unit; + final double end = (start + 1.5 * unit).clamp(0.0, 1.0); CurvedAnimation opacity = new CurvedAnimation( parent: route.animation, curve: new Interval(start, end) ); + final bool disabled = route.items[i].disabled; + Widget item = route.items[i]; + if (route.initialValue != null && route.initialValue == route.items[i].value) { + item = new Container( + decoration: new BoxDecoration(backgroundColor: Theme.of(context).highlightColor), + child: item + ); + } children.add(new FadeTransition( opacity: opacity, child: new InkWell( - onTap: () => Navigator.pop(context, route.items[i].value), - child: route.items[i] - )) - ); + onTap: disabled ? null : () { Navigator.pop(context, route.items[i].value); }, + child: item + ) + )); } final CurveTween opacity = new CurveTween(curve: new Interval(0.0, 1.0 / 3.0)); @@ -117,21 +139,64 @@ class _PopupMenu extends StatelessComponent { } } +class _PopupMenuRouteLayout extends OneChildLayoutDelegate { + _PopupMenuRouteLayout(this.position, this.selectedIndex); + + final ModalPosition position; + final int selectedIndex; + + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return new BoxConstraints( + minWidth: 0.0, + maxWidth: constraints.maxWidth, + minHeight: 0.0, + maxHeight: constraints.maxHeight + ); + } + + // Put the child wherever position specifies, so long as it will fit within the + // specified parent size padded (inset) by 8. If necessary, adjust the child's + // position so that it fits. + Offset getPositionForChild(Size size, Size childSize) { + double x = position?.left + ?? (position?.right != null ? size.width - (position.right + childSize.width) : _kMenuScreenPadding); + double y = position?.top + ?? (position?.bottom != null ? size.height - (position.bottom - childSize.height) : _kMenuScreenPadding); + + if (selectedIndex != -1) + y -= (_kMenuItemHeight * selectedIndex) + _kMenuVerticalPadding + _kMenuItemHeight / 2.0; + + if (x < _kMenuScreenPadding) + x = _kMenuScreenPadding; + else if (x + childSize.width > size.width - 2 * _kMenuScreenPadding) + x = size.width - childSize.width - _kMenuScreenPadding; + if (y < _kMenuScreenPadding) + y = _kMenuScreenPadding; + else if (y + childSize.height > size.height - 2 * _kMenuScreenPadding) + y = size.height - childSize.height - _kMenuScreenPadding; + return new Offset(x, y); + } + + bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { + return position != oldDelegate.position; + } +} + class _PopupMenuRoute extends PopupRoute { _PopupMenuRoute({ Completer completer, this.position, this.items, + this.initialValue, this.elevation }) : super(completer: completer); final ModalPosition position; final List> items; + final dynamic initialValue; final int elevation; - ModalPosition getPosition(BuildContext context) { - return position; - } + ModalPosition getPosition(BuildContext context) => null; Animation createAnimation() { return new CurvedAnimation( @@ -145,17 +210,110 @@ class _PopupMenuRoute extends PopupRoute { Color get barrierColor => null; Widget buildPage(BuildContext context, Animation animation, Animation forwardAnimation) { - return new _PopupMenu(route: this); + int selectedIndex = -1; + if (initialValue != null) { + for (int i = 0; i < items.length; i++) + if (initialValue == items[i].value) { + selectedIndex = i; + break; + } + } + final Size screenSize = MediaQuery.of(context).size; + return new ConstrainedBox( + constraints: new BoxConstraints(maxWidth: screenSize.width, maxHeight: screenSize.height), + child: new CustomOneChildLayout( + delegate: new _PopupMenuRouteLayout(position, selectedIndex), + child: new _PopupMenu(route: this) + ) + ); } } -Future showMenu({ BuildContext context, ModalPosition position, List items, int elevation: 8 }) { - Completer completer = new Completer(); - Navigator.push(context, new _PopupMenuRoute( +/// Show a popup menu that contains the [items] at [position]. If [initialValue] +/// is specified then the first item with a matching value will be highlighted +/// and the value of [position] implies where the left, center point of the +/// highlighted item should appear. If [initialValue] is not specified then position +/// implies the menu's origin. +Future/**/ showMenu/**/({ + BuildContext context, + ModalPosition position, + List*/> items, + dynamic/*=T*/ initialValue, + int elevation: 8 +}) { + assert(context != null); + assert(items != null && items.length > 0); + Completer completer = new Completer/**/(); + Navigator.push(context, new _PopupMenuRoute/**/( completer: completer, position: position, items: items, + initialValue: initialValue, elevation: elevation )); return completer.future; } + +/// A callback that is passed the value of the PopupMenuItem that caused +/// its menu to be dismissed. +typedef void PopupMenuItemSelected(T value); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. If child is null then a standard 'navigation/more_vert' +/// icon is created. +class PopupMenuButton extends StatefulComponent { + PopupMenuButton({ + Key key, + this.items, + this.initialValue, + this.onSelected, + this.tooltip: 'Show menu', + this.elevation: 8, + this.child + }) : super(key: key); + + final List> items; + final T initialValue; + final PopupMenuItemSelected onSelected; + final String tooltip; + final int elevation; + final Widget child; + + _PopupMenuButtonState createState() => new _PopupMenuButtonState(); +} + +class _PopupMenuButtonState extends State> { + void showButtonMenu(BuildContext context) { + final RenderBox renderBox = context.findRenderObject(); + final Point topLeft = renderBox.localToGlobal(Point.origin); + showMenu/**/( + context: context, + elevation: config.elevation, + items: config.items, + initialValue: config.initialValue, + position: new ModalPosition( + left: topLeft.x, + top: topLeft.y + (config.initialValue != null ? renderBox.size.height / 2.0 : 0.0) + ) + ) + .then((T value) { + if (config.onSelected != null) + config.onSelected(value); + }); + } + + Widget build(BuildContext context) { + if (config.child == null) { + return new IconButton( + icon: 'navigation/more_vert', + tooltip: config.tooltip, + onPressed: () { showButtonMenu(context); } + ); + } + return new InkWell( + onTap: () { showButtonMenu(context); }, + child: config.child + ); + } +}