818 lines
26 KiB
Dart
818 lines
26 KiB
Dart
// 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/material.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
|
|
const Duration _kDropdownMenuDuration = Duration(milliseconds: 300);
|
|
const double _kMenuItemHeight = 48.0;
|
|
const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0);
|
|
const EdgeInsetsGeometry _kAlignedButtonPadding =
|
|
EdgeInsetsDirectional.only(start: 16.0, end: 4.0);
|
|
const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero;
|
|
const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero;
|
|
const EdgeInsetsGeometry _kUnalignedMenuMargin =
|
|
EdgeInsetsDirectional.only(start: 16.0, end: 24.0);
|
|
|
|
//TODO: Refactor to allow expansion if no width is provided.
|
|
|
|
class _DropdownMenuPainter extends CustomPainter {
|
|
_DropdownMenuPainter({
|
|
this.color,
|
|
this.elevation,
|
|
this.selectedIndex,
|
|
this.resize,
|
|
}) : _painter = 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: 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) {
|
|
// Changed this tween to avoid animating the top part of the menu.
|
|
final Tween<double> top = Tween<double>(
|
|
begin:
|
|
0.0, //selectedItemOffset.clamp(0.0, size.height - _kMenuItemHeight),
|
|
end: 0.0,
|
|
);
|
|
|
|
final Tween<double> bottom = Tween<double>(
|
|
begin:
|
|
(top.begin + _kMenuItemHeight).clamp(_kMenuItemHeight, size.height),
|
|
end: size.height,
|
|
);
|
|
|
|
// Modified the original recatngle to be flush with the button instead of
|
|
// overflowing its original size.
|
|
final Rect rect = Rect.fromLTRB(16, top.evaluate(resize) + 10,
|
|
size.width - 20, bottom.evaluate(resize));
|
|
|
|
_painter.paint(canvas, rect.topLeft, 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.padding,
|
|
this.route,
|
|
}) : super(key: key);
|
|
|
|
final _DropdownRoute<T> route;
|
|
final EdgeInsets padding;
|
|
|
|
@override
|
|
_DropdownMenuState<T> createState() => _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 = CurvedAnimation(
|
|
parent: widget.route.animation,
|
|
curve: const Interval(0.0, 0.25),
|
|
reverseCurve: const Interval(0.75, 1.0),
|
|
);
|
|
_resize = 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.
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
final MaterialLocalizations localizations =
|
|
MaterialLocalizations.of(context);
|
|
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 = 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 = CurvedAnimation(
|
|
parent: route.animation, curve: Interval(start, end));
|
|
}
|
|
children.add(FadeTransition(
|
|
opacity: opacity,
|
|
// Added padding to compensate for the added offset in the painter.
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
|
child: InkWell(
|
|
child: Container(
|
|
padding: widget.padding,
|
|
child: route.items[itemIndex],
|
|
),
|
|
onTap: () => Navigator.pop(
|
|
context,
|
|
_DropdownRouteResult<T>(route.items[itemIndex].value),
|
|
),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
|
|
return FadeTransition(
|
|
opacity: _fadeOpacity,
|
|
child: CustomPaint(
|
|
painter: _DropdownMenuPainter(
|
|
color: Theme.of(context).canvasColor,
|
|
elevation: route.elevation,
|
|
selectedIndex: route.selectedIndex,
|
|
resize: _resize,
|
|
),
|
|
child: Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
explicitChildNodes: true,
|
|
label: localizations.popupMenuLabel,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
textStyle: route.style,
|
|
child: ScrollConfiguration(
|
|
behavior: const _DropdownScrollBehavior(),
|
|
child: Scrollbar(
|
|
child: 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.io/design/components/menus.html#usage
|
|
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);
|
|
return 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;
|
|
break;
|
|
case TextDirection.ltr:
|
|
left = buttonRect.left.clamp(0.0, size.width - childSize.width);
|
|
break;
|
|
}
|
|
return 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.padding,
|
|
this.buttonRect,
|
|
this.selectedIndex,
|
|
this.elevation = 8,
|
|
this.theme,
|
|
@required this.style,
|
|
this.barrierLabel,
|
|
}) : assert(style != null);
|
|
|
|
final List<CustomDropdownMenuItem<T>> items;
|
|
final EdgeInsetsGeometry padding;
|
|
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 buttonTop = buttonRect.top;
|
|
final double buttonBottom = buttonRect.bottom;
|
|
|
|
// If the button is placed on the bottom or top of the screen, its top or
|
|
// bottom may be less than [_kMenuItemHeight] from the edge of the screen.
|
|
// In this case, we want to change the menu limits to align with the top
|
|
// or bottom edge of the button.
|
|
final double topLimit = math.min(_kMenuItemHeight, buttonTop);
|
|
final double bottomLimit =
|
|
math.max(screenHeight - _kMenuItemHeight, buttonBottom);
|
|
|
|
final double selectedItemOffset =
|
|
selectedIndex * _kMenuItemHeight + kMaterialListPadding.top;
|
|
|
|
double menuTop = (buttonTop - selectedItemOffset) -
|
|
(_kMenuItemHeight - buttonRect.height) / 2.0;
|
|
final double preferredMenuHeight =
|
|
(items.length * _kMenuItemHeight) + kMaterialListPadding.vertical;
|
|
|
|
// If there are too many elements in the menu, we need to shrink it down
|
|
// so it is at most the maxMenuHeight.
|
|
final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight);
|
|
|
|
double menuBottom = menuTop + menuHeight;
|
|
|
|
// If the computed top or bottom of the menu are outside of the range
|
|
// specified, we need to bring them into range. If the item height is larger
|
|
// than the button height and the button is at the very bottom or top of the
|
|
// screen, the menu will be aligned with the bottom or top of the button
|
|
// respectively.
|
|
if (menuTop < topLimit) menuTop = math.min(buttonTop, topLimit);
|
|
if (menuBottom > bottomLimit) {
|
|
menuBottom = math.max(buttonBottom, bottomLimit);
|
|
menuTop = menuBottom - menuHeight;
|
|
}
|
|
|
|
if (scrollController == null) {
|
|
// The limit is asymmetrical because we do not care how far positive the
|
|
// limit goes. We are only concerned about the case where the value of
|
|
// [buttonTop - menuTop] is larger than selectedItemOffset, ie. when
|
|
// the button is close to the bottom of the screen and the selected item
|
|
// is close to 0.
|
|
final double scrollOffset = preferredMenuHeight > maxMenuHeight
|
|
? math.max(0.0, selectedItemOffset - (buttonTop - menuTop))
|
|
: 0.0;
|
|
scrollController = ScrollController(initialScrollOffset: scrollOffset);
|
|
}
|
|
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
Widget menu = _DropdownMenu<T>(
|
|
route: this,
|
|
padding: padding.resolve(textDirection),
|
|
);
|
|
|
|
if (theme != null) menu = Theme(data: theme, child: menu);
|
|
|
|
return MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: true,
|
|
removeBottom: true,
|
|
removeLeft: true,
|
|
removeRight: true,
|
|
child: Builder(
|
|
builder: (BuildContext context) {
|
|
return CustomSingleChildLayout(
|
|
delegate: _DropdownMenuRouteLayout<T>(
|
|
buttonRect: buttonRect,
|
|
menuTop: menuTop,
|
|
menuHeight: menuHeight,
|
|
textDirection: textDirection,
|
|
),
|
|
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 CustomDropdownMenuItem<T> extends StatelessWidget {
|
|
/// Creates an item for a dropdown menu.
|
|
///
|
|
/// The [child] argument is required.
|
|
const CustomDropdownMenuItem({
|
|
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 Container(
|
|
height: _kMenuItemHeight,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 [value] that each dropdown item 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.
|
|
///
|
|
/// The [onChanged] callback should update a state variable that defines the
|
|
/// dropdown's value. It should also call [State.setState] to rebuild the
|
|
/// dropdown with the new value.
|
|
///
|
|
/// {@tool snippet --template=stateful_widget}
|
|
///
|
|
/// This sample shows a `DropdownButton` whose value is one of
|
|
/// "One", "Two", "Free", or "Four".
|
|
///
|
|
/// ```dart preamble
|
|
/// String dropdownValue = 'One';
|
|
/// ```
|
|
///
|
|
/// ```dart
|
|
/// Widget build(BuildContext context) {
|
|
/// return Scaffold(
|
|
/// body: Center(
|
|
/// child: DropdownButton<String>(
|
|
/// value: dropdownValue,
|
|
/// onChanged: (String newValue) {
|
|
/// setState(() {
|
|
/// dropdownValue = newValue;
|
|
/// });
|
|
/// },
|
|
/// items: <String>['One', 'Two', 'Free', 'Four']
|
|
/// .map<DropdownMenuItem<String>>((String value) {
|
|
/// return DropdownMenuItem<String>(
|
|
/// value: value,
|
|
/// child: Text(value),
|
|
/// );
|
|
/// })
|
|
/// .toList(),
|
|
/// ),
|
|
/// ),
|
|
/// );
|
|
/// }
|
|
/// ```
|
|
/// {@end-tool}
|
|
///
|
|
/// If the [onChanged] callback is null or the list of [items] is null
|
|
/// then the dropdown button will be disabled, i.e. its arrow will be
|
|
/// displayed in grey and it will not respond to input. A disabled button
|
|
/// will display the [disabledHint] widget if it is non-null.
|
|
///
|
|
/// 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.io/design/components/menus.html#dropdown-menu>
|
|
class CustomDropdownButton<T> extends StatefulWidget {
|
|
/// Creates a dropdown button.
|
|
///
|
|
/// The [items] must have distinct values. If [value] isn't null then it
|
|
/// must be equal to one of the [DropDownMenuItem] values. If [items] or
|
|
/// [onChanged] is null, the button will be disabled, the down arrow
|
|
/// will be greyed out, and the [disabledHint] will be shown (if provided).
|
|
///
|
|
/// The [elevation] and [iconSize] arguments must not be null (they both have
|
|
/// defaults, so do not need to be specified). The boolean [isDense] and
|
|
/// [isExpanded] arguments must not be null.
|
|
CustomDropdownButton({
|
|
Key key,
|
|
@required this.items,
|
|
this.value,
|
|
this.hint,
|
|
this.disabledHint,
|
|
@required this.onChanged,
|
|
this.elevation = 8,
|
|
this.isElevated = true,
|
|
this.style,
|
|
this.iconSize = 24.0,
|
|
this.isDense = false,
|
|
this.isExpanded = false,
|
|
this.width = 200,
|
|
}) : assert(items == null ||
|
|
items.isEmpty ||
|
|
value == null ||
|
|
items
|
|
.where(
|
|
(CustomDropdownMenuItem<T> item) => item.value == value)
|
|
.length ==
|
|
1),
|
|
assert(elevation != null),
|
|
assert(iconSize != null),
|
|
assert(isDense != null),
|
|
assert(isExpanded != null),
|
|
super(key: key);
|
|
|
|
/// The list of items the user can select.
|
|
///
|
|
/// If the [onChanged] callback is null or the list of items is null
|
|
/// then the dropdown button will be disabled, i.e. its arrow will be
|
|
/// displayed in grey and it will not respond to input. A disabled button
|
|
/// will display the [disabledHint] widget if it is non-null.
|
|
final List<CustomDropdownMenuItem<T>> items;
|
|
|
|
/// The value of the currently selected [DropdownMenuItem], or null if no
|
|
/// item has been selected. If `value` is null then the menu is popped up as
|
|
/// if the first item were selected.
|
|
final T value;
|
|
|
|
/// Displayed if [value] is null.
|
|
final Widget hint;
|
|
|
|
/// A message to show when the dropdown is disabled.
|
|
///
|
|
/// Displayed if [items] or [onChanged] is null.
|
|
final Widget disabledHint;
|
|
|
|
/// Called when the user selects an item.
|
|
///
|
|
/// If the [onChanged] callback is null or the list of [items] is null
|
|
/// then the dropdown button will be disabled, i.e. its arrow will be
|
|
/// displayed in grey and it will not respond to input. A disabled button
|
|
/// will display the [disabledHint] widget if it is non-null.
|
|
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, and 24. See [kElevationToShadow].
|
|
///
|
|
/// Defaults to 8, the appropriate elevation for dropdown buttons.
|
|
final int elevation;
|
|
|
|
final bool isElevated;
|
|
|
|
final double width;
|
|
|
|
/// 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;
|
|
|
|
/// Set the dropdown's inner contents to horizontally fill its parent.
|
|
///
|
|
/// By default this button's inner width is the minimum size of its contents.
|
|
/// If [isExpanded] is true, the inner width is expanded to fill its
|
|
/// surrounding container.
|
|
final bool isExpanded;
|
|
|
|
@override
|
|
_DropdownButtonState<T> createState() => _DropdownButtonState<T>();
|
|
}
|
|
|
|
class _DropdownButtonState<T> extends State<CustomDropdownButton<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(CustomDropdownButton<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_updateSelectedIndex();
|
|
}
|
|
|
|
void _updateSelectedIndex() {
|
|
if (!_enabled) {
|
|
return;
|
|
}
|
|
|
|
assert(widget.value == null ||
|
|
widget.items
|
|
.where((CustomDropdownMenuItem<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;
|
|
final TextDirection textDirection = Directionality.of(context);
|
|
final EdgeInsetsGeometry menuMargin =
|
|
ButtonTheme.of(context).alignedDropdown
|
|
? _kAlignedMenuMargin
|
|
: _kUnalignedMenuMargin;
|
|
|
|
assert(_dropdownRoute == null);
|
|
_dropdownRoute = _DropdownRoute<T>(
|
|
items: widget.items,
|
|
buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
|
|
padding: _kMenuItemPadding.resolve(textDirection),
|
|
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;
|
|
if (widget.onChanged != null) widget.onChanged(newValue.result);
|
|
});
|
|
}
|
|
|
|
bool get _enabled =>
|
|
widget.items != null &&
|
|
widget.items.isNotEmpty &&
|
|
widget.onChanged != null;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterial(context));
|
|
assert(debugCheckHasMaterialLocalizations(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 =
|
|
_enabled ? List<Widget>.from(widget.items) : <Widget>[];
|
|
int hintIndex;
|
|
if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
|
|
final Widget emplacedHint = _enabled
|
|
? widget.hint
|
|
: CustomDropdownMenuItem<Widget>(
|
|
child: widget.disabledHint ?? widget.hint);
|
|
hintIndex = items.length;
|
|
items.add(DefaultTextStyle(
|
|
style: _textStyle.copyWith(
|
|
color: Theme.of(context).cursorColor,
|
|
fontFamily: 'IBM Plex Sans',
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
child: IgnorePointer(
|
|
child: emplacedHint,
|
|
ignoringSemantics: false,
|
|
),
|
|
));
|
|
}
|
|
|
|
final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
|
|
? _kAlignedButtonPadding
|
|
: _kUnalignedButtonPadding;
|
|
|
|
// If value is null (then _selectedIndex is null) or if disabled then we
|
|
// display the hint or nothing at all.
|
|
final int index = _enabled ? (_selectedIndex ?? hintIndex) : hintIndex;
|
|
Widget innerItemsWidget;
|
|
if (items.isEmpty) {
|
|
innerItemsWidget = Container();
|
|
} else {
|
|
innerItemsWidget = IndexedStack(
|
|
index: index,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
children: items,
|
|
);
|
|
}
|
|
|
|
Widget result = DefaultTextStyle(
|
|
style: _textStyle,
|
|
child: Material(
|
|
elevation: widget.isElevated ? 10 : 0,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardColor,
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
padding: padding.resolve(Directionality.of(context)),
|
|
height: 35.0,
|
|
width: widget.width,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
SizedBox(
|
|
width: 15,
|
|
),
|
|
Expanded(child: innerItemsWidget),
|
|
Icon(
|
|
FontAwesomeIcons.chevronCircleDown,
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(
|
|
width: 15,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
onTap: _enabled ? _handleTap : null,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: result),
|
|
);
|
|
}
|
|
}
|