// Copyright 2015 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 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; import 'icons.dart'; import 'ink_well.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'scrollbar.dart'; import 'shadows.dart'; import 'theme.dart'; const Duration _kDropdownMenuDuration = const Duration(milliseconds: 300); const double _kMenuItemHeight = 48.0; const double _kDenseButtonHeight = 24.0; const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0); class _DropdownMenuPainter extends CustomPainter { _DropdownMenuPainter({ this.color, this.elevation, this.selectedIndex, this.resize, }) : _painter = new BoxDecoration( // If you add an image here, you must provide a real // configuration in the paint() function and you must provide some sort // of onChanged callback here. color: color, borderRadius: new BorderRadius.circular(2.0), boxShadow: kElevationToShadow[elevation] ).createBoxPainter(), super(repaint: resize); final Color color; final int elevation; final int selectedIndex; final Animation<double> resize; final BoxPainter _painter; @override void paint(Canvas canvas, Size size) { final double selectedItemOffset = selectedIndex * _kMenuItemHeight + kMaterialListPadding.top; final Tween<double> top = new Tween<double>( begin: selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight), end: 0.0, ); final Tween<double> bottom = new Tween<double>( begin: (top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height), end: size.height, ); final Rect rect = new Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize)); _painter.paint(canvas, rect.topLeft, new ImageConfiguration(size: rect.size)); } @override bool shouldRepaint(_DropdownMenuPainter oldPainter) { return oldPainter.color != color || oldPainter.elevation != elevation || oldPainter.selectedIndex != selectedIndex || oldPainter.resize != resize; } } // Do not use the platform-specific default scroll configuration. // Dropdown menus should never overscroll or display an overscroll indicator. class _DropdownScrollBehavior extends ScrollBehavior { const _DropdownScrollBehavior(); @override TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform; @override Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) => child; @override ScrollPhysics getScrollPhysics(BuildContext context) => const ClampingScrollPhysics(); } class _DropdownMenu<T> extends StatefulWidget { const _DropdownMenu({ Key key, this.route, }) : super(key: key); final _DropdownRoute<T> route; @override _DropdownMenuState<T> createState() => new _DropdownMenuState<T>(); } class _DropdownMenuState<T> extends State<_DropdownMenu<T>> { CurvedAnimation _fadeOpacity; CurvedAnimation _resize; @override void initState() { super.initState(); // We need to hold these animations as state because of their curve // direction. When the route's animation reverses, if we were to recreate // the CurvedAnimation objects in build, we'd lose // CurvedAnimation._curveDirection. _fadeOpacity = new CurvedAnimation( parent: widget.route.animation, curve: const Interval(0.0, 0.25), reverseCurve: const Interval(0.75, 1.0), ); _resize = new CurvedAnimation( parent: widget.route.animation, curve: const Interval(0.25, 0.5), reverseCurve: const Threshold(0.0), ); } @override Widget build(BuildContext context) { // The menu is shown in three stages (unit timing in brackets): // [0s - 0.25s] - Fade in a rect-sized menu container with the selected item. // [0.25s - 0.5s] - Grow the otherwise empty menu container from the center // until it's big enough for as many items as we're going to show. // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom. // // When the menu is dismissed we just fade the entire thing out // in the first 0.25s. final _DropdownRoute<T> route = widget.route; final double unit = 0.5 / (route.items.length + 1.5); final List<Widget> children = <Widget>[]; for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) { CurvedAnimation opacity; if (itemIndex == route.selectedIndex) { opacity = new CurvedAnimation(parent: route.animation, curve: const Threshold(0.0)); } else { final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0); final double end = (start + 1.5 * unit).clamp(0.0, 1.0); opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end)); } children.add(new FadeTransition( opacity: opacity, child: new InkWell( child: new Container( padding: _kMenuHorizontalPadding, child: route.items[itemIndex], ), onTap: () => Navigator.pop( context, new _DropdownRouteResult<T>(route.items[itemIndex].value), ), ), )); } return new FadeTransition( opacity: _fadeOpacity, child: new CustomPaint( painter: new _DropdownMenuPainter( color: Theme.of(context).canvasColor, elevation: route.elevation, selectedIndex: route.selectedIndex, resize: _resize, ), child: new Material( type: MaterialType.transparency, textStyle: route.style, child: new ScrollConfiguration( behavior: const _DropdownScrollBehavior(), child: new Scrollbar( child: new ListView( controller: widget.route.scrollController, padding: kMaterialListPadding, itemExtent: _kMenuItemHeight, shrinkWrap: true, children: children, ), ), ), ), ), ); } } class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate { _DropdownMenuRouteLayout({ @required this.buttonRect, @required this.menuTop, @required this.menuHeight, @required this.textDirection, }); final Rect buttonRect; final double menuTop; final double menuHeight; final TextDirection textDirection; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { // The maximum height of a simple menu should be one or more rows less than // the view height. This ensures a tappable area outside of the simple menu // with which to dismiss the menu. // -- https://material.google.com/components/menus.html#menus-simple-menus final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight); // The width of a menu should be at most the view width. This ensures that // the menu does not extend past the left and right edges of the screen. final double width = math.min(constraints.maxWidth, buttonRect.width + 8.0); return new BoxConstraints( minWidth: width, maxWidth: width, minHeight: 0.0, maxHeight: maxHeight, ); } @override Offset getPositionForChild(Size size, Size childSize) { assert(() { final Rect container = Offset.zero & size; if (container.intersect(buttonRect) == buttonRect) { // If the button was entirely on-screen, then verify // that the menu is also on-screen. // If the button was a bit off-screen, then, oh well. assert(menuTop >= 0.0); assert(menuTop + menuHeight <= size.height); } return true; }()); assert(textDirection != null); double left; switch (textDirection) { case TextDirection.rtl: left = buttonRect.right.clamp(0.0, size.width - childSize.width) - childSize.width; break; case TextDirection.ltr: left = buttonRect.left.clamp(0.0, size.width - childSize.width); break; } return new Offset(left, menuTop); } @override bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) { return buttonRect != oldDelegate.buttonRect || menuTop != oldDelegate.menuTop || menuHeight != oldDelegate.menuHeight || textDirection != oldDelegate.textDirection; } } // We box the return value so that the return value can be null. Otherwise, // canceling the route (which returns null) would get confused with actually // returning a real null value. class _DropdownRouteResult<T> { const _DropdownRouteResult(this.result); final T result; @override bool operator ==(dynamic other) { if (other is! _DropdownRouteResult<T>) return false; final _DropdownRouteResult<T> typedOther = other; return result == typedOther.result; } @override int get hashCode => result.hashCode; } class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { _DropdownRoute({ this.items, this.buttonRect, this.selectedIndex, this.elevation: 8, this.theme, @required this.style, this.barrierLabel, }) : assert(style != null); final List<DropdownMenuItem<T>> items; final Rect buttonRect; final int selectedIndex; final int elevation; final ThemeData theme; final TextStyle style; ScrollController scrollController; @override Duration get transitionDuration => _kDropdownMenuDuration; @override bool get barrierDismissible => true; @override Color get barrierColor => null; @override final String barrierLabel; @override Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { assert(debugCheckHasDirectionality(context)); final double screenHeight = MediaQuery.of(context).size.height; final double maxMenuHeight = screenHeight - 2.0 * _kMenuItemHeight; final double preferredMenuHeight = (items.length * _kMenuItemHeight) + kMaterialListPadding.vertical; final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight); final double buttonTop = buttonRect.top; final double selectedItemOffset = selectedIndex * _kMenuItemHeight + kMaterialListPadding.top; double menuTop = (buttonTop - selectedItemOffset) - (_kMenuItemHeight - buttonRect.height) / 2.0; final double topPreferredLimit = _kMenuItemHeight; if (menuTop < topPreferredLimit) menuTop = math.min(buttonTop, topPreferredLimit); double bottom = menuTop + menuHeight; final double bottomPreferredLimit = screenHeight - _kMenuItemHeight; if (bottom > bottomPreferredLimit) { bottom = math.max(buttonTop + _kMenuItemHeight, bottomPreferredLimit); menuTop = bottom - menuHeight; } if (scrollController == null) { double scrollOffset = 0.0; if (preferredMenuHeight > maxMenuHeight) scrollOffset = selectedItemOffset - (buttonTop - menuTop); scrollController = new ScrollController(initialScrollOffset: scrollOffset); } Widget menu = new _DropdownMenu<T>(route: this); if (theme != null) menu = new Theme(data: theme, child: menu); return new MediaQuery.removePadding( context: context, removeTop: true, removeBottom: true, removeLeft: true, removeRight: true, child: new Builder( builder: (BuildContext context) { return new CustomSingleChildLayout( delegate: new _DropdownMenuRouteLayout<T>( buttonRect: buttonRect, menuTop: menuTop, menuHeight: menuHeight, textDirection: Directionality.of(context), ), child: menu, ); }, ), ); } void _dismiss() { navigator?.removeRoute(this); } } /// An item in a menu created by a [DropdownButton]. /// /// The type `T` is the type of the value the entry represents. All the entries /// in a given menu must represent values with consistent types. class DropdownMenuItem<T> extends StatelessWidget { /// Creates an item for a dropdown menu. /// /// The [child] argument is required. const DropdownMenuItem({ Key key, this.value, @required this.child, }) : assert(child != null), super(key: key); /// The widget below this widget in the tree. /// /// Typically a [Text] widget. final Widget child; /// The value to return if the user selects this menu item. /// /// Eventually returned in a call to [DropdownButton.onChanged]. final T value; @override Widget build(BuildContext context) { return new Container( height: _kMenuItemHeight, alignment: AlignmentDirectional.centerStart, child: child, ); } } /// An inherited widget that causes any descendant [DropdownButton] /// widgets to not include their regular underline. /// /// This is used by [DataTable] to remove the underline from any /// [DropdownButton] widgets placed within material data tables, as /// required by the material design specification. class DropdownButtonHideUnderline extends InheritedWidget { /// Creates a [DropdownButtonHideUnderline]. A non-null [child] must /// be given. const DropdownButtonHideUnderline({ Key key, @required Widget child, }) : assert(child != null), super(key: key, child: child); /// Returns whether the underline of [DropdownButton] widgets should /// be hidden. static bool at(BuildContext context) { return context.inheritFromWidgetOfExactType(DropdownButtonHideUnderline) != null; } @override bool updateShouldNotify(DropdownButtonHideUnderline old) => false; } /// A material design button for selecting from a list of items. /// /// A dropdown button lets the user select from a number of items. The button /// shows the currently selected item as well as an arrow that opens a menu for /// selecting another item. /// /// The type `T` is the type of the values the dropdown menu represents. All the /// entries in a given menu must represent values with consistent types. /// Typically, an enum is used. Each [DropdownMenuItem] in [items] must be /// specialized with that same type argument. /// /// Requires one of its ancestors to be a [Material] widget. /// /// See also: /// /// * [DropdownMenuItem], the class used to represent the [items]. /// * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons /// from displaying their underlines. /// * [RaisedButton], [FlatButton], ordinary buttons that trigger a single action. /// * <https://material.google.com/components/buttons.html#buttons-dropdown-buttons> class DropdownButton<T> extends StatefulWidget { /// Creates a dropdown button. /// /// The [items] must have distinct values and if [value] isn't null it must be among them. /// /// The [elevation] and [iconSize] arguments must not be null (they both have /// defaults, so do not need to be specified). DropdownButton({ Key key, @required this.items, this.value, this.hint, @required this.onChanged, this.elevation: 8, this.style, this.iconSize: 24.0, this.isDense: false, }) : assert(items != null), assert(value == null || items.where((DropdownMenuItem<T> item) => item.value == value).length == 1), super(key: key); /// The list of possible items to select among. final List<DropdownMenuItem<T>> items; /// The currently selected item, or null if no item has been selected. If /// value is null then the menu is popped up as if the first item was /// selected. final T value; /// Displayed if [value] is null. final Widget hint; /// Called when the user selects an item. final ValueChanged<T> onChanged; /// The z-coordinate at which to place the menu when open. /// /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24 /// /// Defaults to 8, the appropriate elevation for dropdown buttons. final int elevation; /// The text style to use for text in the dropdown button and the dropdown /// menu that appears when you tap the button. /// /// Defaults to the [TextTheme.subhead] value of the current /// [ThemeData.textTheme] of the current [Theme]. final TextStyle style; /// The size to use for the drop-down button's down arrow icon button. /// /// Defaults to 24.0. final double iconSize; /// Reduce the button's height. /// /// By default this button's height is the same as its menu items' heights. /// If isDense is true, the button's height is reduced by about half. This /// can be useful when the button is embedded in a container that adds /// its own decorations, like [InputDecorator]. final bool isDense; @override _DropdownButtonState<T> createState() => new _DropdownButtonState<T>(); } class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindingObserver { int _selectedIndex; _DropdownRoute<T> _dropdownRoute; @override void initState() { super.initState(); _updateSelectedIndex(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _removeDropdownRoute(); super.dispose(); } // Typically called because the device's orientation has changed. // Defined by WidgetsBindingObserver @override void didChangeMetrics() { _removeDropdownRoute(); } void _removeDropdownRoute() { _dropdownRoute?._dismiss(); _dropdownRoute = null; } @override void didUpdateWidget(DropdownButton<T> oldWidget) { super.didUpdateWidget(oldWidget); _updateSelectedIndex(); } void _updateSelectedIndex() { assert(widget.value == null || widget.items.where((DropdownMenuItem<T> item) => item.value == widget.value).length == 1); _selectedIndex = null; for (int itemIndex = 0; itemIndex < widget.items.length; itemIndex++) { if (widget.items[itemIndex].value == widget.value) { _selectedIndex = itemIndex; return; } } } TextStyle get _textStyle => widget.style ?? Theme.of(context).textTheme.subhead; void _handleTap() { final RenderBox itemBox = context.findRenderObject(); final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; assert(_dropdownRoute == null); _dropdownRoute = new _DropdownRoute<T>( items: widget.items, buttonRect: _kMenuHorizontalPadding.inflateRect(itemRect), selectedIndex: _selectedIndex ?? 0, elevation: widget.elevation, theme: Theme.of(context, shadowThemeOnly: true), style: _textStyle, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, ); Navigator.push(context, _dropdownRoute).then<void>((_DropdownRouteResult<T> newValue) { _dropdownRoute = null; if (!mounted || newValue == null) return null; if (widget.onChanged != null) widget.onChanged(newValue.result); }); } // When isDense is true, reduce the height of this button from _kMenuItemHeight to // _kDenseButtonHeight, but don't make it smaller than the text that it contains. // Similarly, we don't reduce the height of the button so much that its icon // would be clipped. double get _denseButtonHeight { return math.max(_textStyle.fontSize, math.max(widget.iconSize, _kDenseButtonHeight)); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); // The width of the button and the menu are defined by the widest // item and the width of the hint. final List<Widget> items = new List<Widget>.from(widget.items); int hintIndex; if (widget.hint != null) { hintIndex = items.length; items.add(new DefaultTextStyle( style: _textStyle.copyWith(color: Theme.of(context).hintColor), child: new IgnorePointer( child: widget.hint, ), )); } Widget result = new DefaultTextStyle( style: _textStyle, child: new SizedBox( height: widget.isDense ? _denseButtonHeight : null, child: new Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, children: <Widget>[ // If value is null (then _selectedIndex is null) then we display // the hint or nothing at all. new IndexedStack( index: _selectedIndex ?? hintIndex, alignment: AlignmentDirectional.centerStart, children: items, ), new Icon(Icons.arrow_drop_down, size: widget.iconSize, // These colors are not defined in the Material Design spec. color: Theme.of(context).brightness == Brightness.light ? Colors.grey.shade700 : Colors.white70 ), ], ), ), ); if (!DropdownButtonHideUnderline.at(context)) { final double bottom = widget.isDense ? 0.0 : 8.0; result = new Stack( children: <Widget>[ result, new Positioned( left: 0.0, right: 0.0, bottom: bottom, child: new Container( height: 1.0, decoration: const BoxDecoration( border: const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 0.0)) ), ), ), ], ); } return new GestureDetector( onTap: _handleTap, behavior: HitTestBehavior.opaque, child: result ); } }