customdropdownbutton.dart 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768
  1. // Copyright 2014 The Flutter Authors. All rights reserved.
  2. // Use of this source code is governed by a BSD-style license that can be
  3. // found in the LICENSE file.
  4. import 'dart:math' as math;
  5. import 'package:flutter/foundation.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:flutter/rendering.dart';
  8. import 'package:flutter/services.dart';
  9. import 'package:flyinsonolite/infrastructure/scale.dart';
  10. const Duration _kDropdownMenuDuration = Duration(milliseconds: 300);
  11. double get _kMenuItemHeight => kMinInteractiveDimension.s;
  12. double get _kDenseButtonHeight => 24.s;
  13. EdgeInsets get _kMenuItemPadding => EdgeInsets.symmetric(horizontal: 16.s);
  14. EdgeInsetsGeometry get _kAlignedButtonPadding =>
  15. EdgeInsetsDirectional.only(start: 16.s, end: 4.s);
  16. const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero;
  17. const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero;
  18. EdgeInsetsGeometry get _kUnalignedMenuMargin =>
  19. EdgeInsetsDirectional.only(start: 16.s, end: 24.s);
  20. /// A builder to customize dropdown buttons.
  21. ///
  22. /// Used by [CustomDropdownButton.selectedItemBuilder].
  23. typedef DropdownButtonBuilder = List<Widget> Function(BuildContext context);
  24. class _DropdownMenuPainter extends CustomPainter {
  25. _DropdownMenuPainter({
  26. this.color,
  27. this.elevation,
  28. this.selectedIndex,
  29. this.borderRadius,
  30. required this.resize,
  31. required this.getSelectedItemOffset,
  32. }) : _painter = BoxDecoration(
  33. // If you add an image here, you must provide a real
  34. // configuration in the paint() function and you must provide some sort
  35. // of onChanged callback here.
  36. color: color,
  37. borderRadius:
  38. borderRadius ?? const BorderRadius.all(Radius.circular(2.0)),
  39. boxShadow: kElevationToShadow[elevation],
  40. ).createBoxPainter(),
  41. super(repaint: resize);
  42. final Color? color;
  43. final int? elevation;
  44. final int? selectedIndex;
  45. final BorderRadius? borderRadius;
  46. final Animation<double> resize;
  47. final ValueGetter<double> getSelectedItemOffset;
  48. final BoxPainter _painter;
  49. @override
  50. void paint(Canvas canvas, Size size) {
  51. final double selectedItemOffset = getSelectedItemOffset();
  52. final Tween<double> top = Tween<double>(
  53. begin: clampDouble(selectedItemOffset, 0.0,
  54. math.max(size.height - _kMenuItemHeight, 0.0)),
  55. end: 0.0,
  56. );
  57. final Tween<double> bottom = Tween<double>(
  58. begin: clampDouble(top.begin! + _kMenuItemHeight,
  59. math.min(_kMenuItemHeight, size.height), size.height),
  60. end: size.height,
  61. );
  62. final Rect rect = Rect.fromLTRB(
  63. 0.0, top.evaluate(resize), size.width, bottom.evaluate(resize));
  64. _painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
  65. }
  66. @override
  67. bool shouldRepaint(_DropdownMenuPainter oldPainter) {
  68. return oldPainter.color != color ||
  69. oldPainter.elevation != elevation ||
  70. oldPainter.selectedIndex != selectedIndex ||
  71. oldPainter.borderRadius != borderRadius ||
  72. oldPainter.resize != resize;
  73. }
  74. }
  75. // The widget that is the button wrapping the menu items.
  76. class _DropdownMenuItemButton<T> extends StatefulWidget {
  77. const _DropdownMenuItemButton({
  78. super.key,
  79. this.padding,
  80. required this.route,
  81. required this.buttonRect,
  82. required this.constraints,
  83. required this.itemIndex,
  84. required this.enableFeedback,
  85. });
  86. final _DropdownRoute<T> route;
  87. final EdgeInsets? padding;
  88. final Rect buttonRect;
  89. final BoxConstraints constraints;
  90. final int itemIndex;
  91. final bool enableFeedback;
  92. @override
  93. _DropdownMenuItemButtonState<T> createState() =>
  94. _DropdownMenuItemButtonState<T>();
  95. }
  96. class _DropdownMenuItemButtonState<T>
  97. extends State<_DropdownMenuItemButton<T>> {
  98. void _handleFocusChange(bool focused) {
  99. final bool inTraditionalMode;
  100. switch (FocusManager.instance.highlightMode) {
  101. case FocusHighlightMode.touch:
  102. inTraditionalMode = false;
  103. break;
  104. case FocusHighlightMode.traditional:
  105. inTraditionalMode = true;
  106. break;
  107. }
  108. if (focused && inTraditionalMode) {
  109. final _MenuLimits menuLimits = widget.route.getMenuLimits(
  110. widget.buttonRect,
  111. widget.constraints.maxHeight,
  112. widget.itemIndex,
  113. );
  114. widget.route.scrollController!.animateTo(
  115. menuLimits.scrollOffset,
  116. curve: Curves.easeInOut,
  117. duration: const Duration(milliseconds: 100),
  118. );
  119. }
  120. }
  121. void _handleOnTap() {
  122. final CustomDropdownMenuItem<T> dropdownMenuItem =
  123. widget.route.items[widget.itemIndex].item!;
  124. dropdownMenuItem.onTap?.call();
  125. Navigator.pop(
  126. context,
  127. _DropdownRouteResult<T>(dropdownMenuItem.value),
  128. );
  129. }
  130. static const Map<ShortcutActivator, Intent> _webShortcuts =
  131. <ShortcutActivator, Intent>{
  132. // On the web, up/down don't change focus, *except* in a <select>
  133. // element, which is what a dropdown emulates.
  134. SingleActivator(LogicalKeyboardKey.arrowDown):
  135. DirectionalFocusIntent(TraversalDirection.down),
  136. SingleActivator(LogicalKeyboardKey.arrowUp):
  137. DirectionalFocusIntent(TraversalDirection.up),
  138. };
  139. @override
  140. Widget build(BuildContext context) {
  141. final CustomDropdownMenuItem<T> dropdownMenuItem =
  142. widget.route.items[widget.itemIndex].item!;
  143. final CurvedAnimation opacity;
  144. final double unit = 0.5 / (widget.route.items.length + 1.5);
  145. if (widget.itemIndex == widget.route.selectedIndex) {
  146. opacity = CurvedAnimation(
  147. parent: widget.route.animation!, curve: const Threshold(0.0));
  148. } else {
  149. final double start =
  150. clampDouble(0.5 + (widget.itemIndex + 1) * unit, 0.0, 1.0);
  151. final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0);
  152. opacity = CurvedAnimation(
  153. parent: widget.route.animation!, curve: Interval(start, end));
  154. }
  155. Widget child = Container(
  156. padding: widget.padding,
  157. height: widget.route.itemHeight,
  158. child: widget.route.items[widget.itemIndex],
  159. );
  160. // An [InkWell] is added to the item only if it is enabled
  161. if (dropdownMenuItem.enabled) {
  162. child = InkWell(
  163. autofocus: widget.itemIndex == widget.route.selectedIndex,
  164. enableFeedback: widget.enableFeedback,
  165. onTap: _handleOnTap,
  166. onFocusChange: _handleFocusChange,
  167. child: child,
  168. );
  169. }
  170. child = FadeTransition(opacity: opacity, child: child);
  171. if (kIsWeb && dropdownMenuItem.enabled) {
  172. child = Shortcuts(
  173. shortcuts: _webShortcuts,
  174. child: child,
  175. );
  176. }
  177. return child;
  178. }
  179. }
  180. class _DropdownMenu<T> extends StatefulWidget {
  181. const _DropdownMenu({
  182. super.key,
  183. this.padding,
  184. required this.route,
  185. required this.buttonRect,
  186. required this.constraints,
  187. this.dropdownColor,
  188. required this.enableFeedback,
  189. this.borderRadius,
  190. });
  191. final _DropdownRoute<T> route;
  192. final EdgeInsets? padding;
  193. final Rect buttonRect;
  194. final BoxConstraints constraints;
  195. final Color? dropdownColor;
  196. final bool enableFeedback;
  197. final BorderRadius? borderRadius;
  198. @override
  199. _DropdownMenuState<T> createState() => _DropdownMenuState<T>();
  200. }
  201. class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
  202. late CurvedAnimation _fadeOpacity;
  203. late CurvedAnimation _resize;
  204. @override
  205. void initState() {
  206. super.initState();
  207. // We need to hold these animations as state because of their curve
  208. // direction. When the route's animation reverses, if we were to recreate
  209. // the CurvedAnimation objects in build, we'd lose
  210. // CurvedAnimation._curveDirection.
  211. _fadeOpacity = CurvedAnimation(
  212. parent: widget.route.animation!,
  213. curve: const Interval(0.0, 0.25),
  214. reverseCurve: const Interval(0.75, 1.0),
  215. );
  216. _resize = CurvedAnimation(
  217. parent: widget.route.animation!,
  218. curve: const Interval(0.25, 0.5),
  219. reverseCurve: const Threshold(0.0),
  220. );
  221. }
  222. @override
  223. Widget build(BuildContext context) {
  224. // The menu is shown in three stages (unit timing in brackets):
  225. // [0s - 0.25s] - Fade in a rect-sized menu container with the selected item.
  226. // [0.25s - 0.5s] - Grow the otherwise empty menu container from the center
  227. // until it's big enough for as many items as we're going to show.
  228. // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
  229. //
  230. // When the menu is dismissed we just fade the entire thing out
  231. // in the first 0.25s.
  232. assert(debugCheckHasMaterialLocalizations(context));
  233. final MaterialLocalizations localizations =
  234. MaterialLocalizations.of(context);
  235. final _DropdownRoute<T> route = widget.route;
  236. final List<Widget> children = <Widget>[
  237. for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex)
  238. _DropdownMenuItemButton<T>(
  239. route: widget.route,
  240. padding: widget.padding,
  241. buttonRect: widget.buttonRect,
  242. constraints: widget.constraints,
  243. itemIndex: itemIndex,
  244. enableFeedback: widget.enableFeedback,
  245. ),
  246. ];
  247. return FadeTransition(
  248. opacity: _fadeOpacity,
  249. child: CustomPaint(
  250. painter: _DropdownMenuPainter(
  251. color: widget.dropdownColor ?? Theme.of(context).canvasColor,
  252. elevation: route.elevation,
  253. selectedIndex: route.selectedIndex,
  254. resize: _resize,
  255. borderRadius: widget.borderRadius,
  256. // This offset is passed as a callback, not a value, because it must
  257. // be retrieved at paint time (after layout), not at build time.
  258. getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex),
  259. ),
  260. child: Semantics(
  261. scopesRoute: true,
  262. namesRoute: true,
  263. explicitChildNodes: true,
  264. label: localizations.popupMenuLabel,
  265. child: ClipRRect(
  266. borderRadius: widget.borderRadius ?? BorderRadius.zero,
  267. clipBehavior:
  268. widget.borderRadius != null ? Clip.antiAlias : Clip.none,
  269. child: Material(
  270. type: MaterialType.transparency,
  271. textStyle: route.style,
  272. child: ScrollConfiguration(
  273. // Dropdown menus should never overscroll or display an overscroll indicator.
  274. // Scrollbars are built-in below.
  275. // Platform must use Theme and ScrollPhysics must be Clamping.
  276. behavior: ScrollConfiguration.of(context).copyWith(
  277. scrollbars: false,
  278. overscroll: false,
  279. physics: const ClampingScrollPhysics(),
  280. platform: Theme.of(context).platform,
  281. ),
  282. child: PrimaryScrollController(
  283. controller: widget.route.scrollController!,
  284. child: Scrollbar(
  285. thumbVisibility: true,
  286. child: ListView(
  287. // Ensure this always inherits the PrimaryScrollController
  288. primary: true,
  289. padding: kMaterialListPadding,
  290. shrinkWrap: true,
  291. children: children,
  292. ),
  293. ),
  294. ),
  295. ),
  296. ),
  297. ),
  298. ),
  299. ),
  300. );
  301. }
  302. }
  303. class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate {
  304. _DropdownMenuRouteLayout({
  305. required this.buttonRect,
  306. required this.route,
  307. required this.textDirection,
  308. });
  309. final Rect buttonRect;
  310. final _DropdownRoute<T> route;
  311. final TextDirection? textDirection;
  312. @override
  313. BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
  314. // The maximum height of a simple menu should be one or more rows less than
  315. // the view height. This ensures a tappable area outside of the simple menu
  316. // with which to dismiss the menu.
  317. // -- https://material.io/design/components/menus.html#usage
  318. double maxHeight =
  319. math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight);
  320. if (route.menuMaxHeight != null && route.menuMaxHeight! <= maxHeight) {
  321. maxHeight = route.menuMaxHeight!;
  322. }
  323. // The width of a menu should be at most the view width. This ensures that
  324. // the menu does not extend past the left and right edges of the screen.
  325. final double width = math.min(constraints.maxWidth, buttonRect.width);
  326. return BoxConstraints(
  327. minWidth: width,
  328. maxWidth: width,
  329. maxHeight: maxHeight,
  330. );
  331. }
  332. @override
  333. Offset getPositionForChild(Size size, Size childSize) {
  334. final _MenuLimits menuLimits =
  335. route.getMenuLimits(buttonRect, size.height, route.selectedIndex);
  336. assert(() {
  337. final Rect container = Offset.zero & size;
  338. if (container.intersect(buttonRect) == buttonRect) {
  339. // If the button was entirely on-screen, then verify
  340. // that the menu is also on-screen.
  341. // If the button was a bit off-screen, then, oh well.
  342. assert(menuLimits.top >= 0.0);
  343. assert(menuLimits.top + menuLimits.height <= size.height);
  344. }
  345. return true;
  346. }());
  347. assert(textDirection != null);
  348. final double left;
  349. switch (textDirection!) {
  350. case TextDirection.rtl:
  351. left = clampDouble(buttonRect.right, 0.0, size.width) - childSize.width;
  352. break;
  353. case TextDirection.ltr:
  354. left = clampDouble(buttonRect.left, 0.0, size.width - childSize.width);
  355. break;
  356. }
  357. return Offset(left, menuLimits.top);
  358. }
  359. @override
  360. bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) {
  361. return buttonRect != oldDelegate.buttonRect ||
  362. textDirection != oldDelegate.textDirection;
  363. }
  364. }
  365. // We box the return value so that the return value can be null. Otherwise,
  366. // canceling the route (which returns null) would get confused with actually
  367. // returning a real null value.
  368. @immutable
  369. class _DropdownRouteResult<T> {
  370. const _DropdownRouteResult(this.result);
  371. final T? result;
  372. @override
  373. bool operator ==(Object other) {
  374. return other is _DropdownRouteResult<T> && other.result == result;
  375. }
  376. @override
  377. int get hashCode => result.hashCode;
  378. }
  379. class _MenuLimits {
  380. const _MenuLimits(this.top, this.bottom, this.height, this.scrollOffset);
  381. final double top;
  382. final double bottom;
  383. final double height;
  384. final double scrollOffset;
  385. }
  386. class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
  387. _DropdownRoute({
  388. required this.items,
  389. required this.padding,
  390. required this.buttonRect,
  391. required this.selectedIndex,
  392. this.elevation = 8,
  393. required this.capturedThemes,
  394. required this.style,
  395. this.barrierLabel,
  396. this.itemHeight,
  397. this.dropdownColor,
  398. this.menuMaxHeight,
  399. required this.enableFeedback,
  400. this.borderRadius,
  401. }) : assert(style != null),
  402. itemHeights = List<double>.filled(
  403. items.length, itemHeight ?? kMinInteractiveDimension.s);
  404. final List<_MenuItem<T>> items;
  405. final EdgeInsetsGeometry padding;
  406. final Rect buttonRect;
  407. final int selectedIndex;
  408. final int elevation;
  409. final CapturedThemes capturedThemes;
  410. final TextStyle style;
  411. final double? itemHeight;
  412. final Color? dropdownColor;
  413. final double? menuMaxHeight;
  414. final bool enableFeedback;
  415. final BorderRadius? borderRadius;
  416. final List<double> itemHeights;
  417. ScrollController? scrollController;
  418. @override
  419. Duration get transitionDuration => _kDropdownMenuDuration;
  420. @override
  421. bool get barrierDismissible => true;
  422. @override
  423. Color? get barrierColor => null;
  424. @override
  425. final String? barrierLabel;
  426. @override
  427. Widget buildPage(BuildContext context, Animation<double> animation,
  428. Animation<double> secondaryAnimation) {
  429. return LayoutBuilder(
  430. builder: (BuildContext context, BoxConstraints constraints) {
  431. return _DropdownRoutePage<T>(
  432. route: this,
  433. constraints: constraints,
  434. items: items,
  435. padding: padding,
  436. buttonRect: buttonRect,
  437. selectedIndex: selectedIndex,
  438. elevation: elevation,
  439. capturedThemes: capturedThemes,
  440. style: style,
  441. dropdownColor: dropdownColor,
  442. enableFeedback: enableFeedback,
  443. borderRadius: borderRadius,
  444. );
  445. },
  446. );
  447. }
  448. void _dismiss() {
  449. if (isActive) {
  450. navigator?.removeRoute(this);
  451. }
  452. }
  453. double getItemOffset(int index) {
  454. double offset = kMaterialListPadding.top;
  455. if (items.isNotEmpty && index > 0) {
  456. assert(items.length == itemHeights.length);
  457. offset += itemHeights
  458. .sublist(0, index)
  459. .reduce((double total, double height) => total + height);
  460. }
  461. return offset;
  462. }
  463. // Returns the vertical extent of the menu and the initial scrollOffset
  464. // for the ListView that contains the menu items. The vertical center of the
  465. // selected item is aligned with the button's vertical center, as far as
  466. // that's possible given availableHeight.
  467. _MenuLimits getMenuLimits(
  468. Rect buttonRect, double availableHeight, int index) {
  469. double computedMaxHeight = availableHeight - 2.0 * _kMenuItemHeight;
  470. if (menuMaxHeight != null) {
  471. computedMaxHeight = math.min(computedMaxHeight, menuMaxHeight!);
  472. }
  473. final double buttonTop = buttonRect.top;
  474. final double buttonBottom = math.min(buttonRect.bottom, availableHeight);
  475. final double selectedItemOffset = getItemOffset(index);
  476. // If the button is placed on the bottom or top of the screen, its top or
  477. // bottom may be less than [_kMenuItemHeight] from the edge of the screen.
  478. // In this case, we want to change the menu limits to align with the top
  479. // or bottom edge of the button.
  480. final double topLimit = math.min(_kMenuItemHeight, buttonTop);
  481. final double bottomLimit =
  482. math.max(availableHeight - _kMenuItemHeight, buttonBottom);
  483. double menuTop = (buttonTop - selectedItemOffset) -
  484. (itemHeights[selectedIndex] - buttonRect.height) / 2.0;
  485. double preferredMenuHeight = kMaterialListPadding.vertical;
  486. if (items.isNotEmpty) {
  487. preferredMenuHeight +=
  488. itemHeights.reduce((double total, double height) => total + height);
  489. }
  490. // If there are too many elements in the menu, we need to shrink it down
  491. // so it is at most the computedMaxHeight.
  492. final double menuHeight = math.min(computedMaxHeight, preferredMenuHeight);
  493. double menuBottom = menuTop + menuHeight;
  494. // If the computed top or bottom of the menu are outside of the range
  495. // specified, we need to bring them into range. If the item height is larger
  496. // than the button height and the button is at the very bottom or top of the
  497. // screen, the menu will be aligned with the bottom or top of the button
  498. // respectively.
  499. if (menuTop < topLimit) {
  500. menuTop = math.min(buttonTop, topLimit);
  501. menuBottom = menuTop + menuHeight;
  502. }
  503. if (menuBottom > bottomLimit) {
  504. menuBottom = math.max(buttonBottom, bottomLimit);
  505. menuTop = menuBottom - menuHeight;
  506. }
  507. if (menuBottom - itemHeights[selectedIndex] / 2.0 <
  508. buttonBottom - buttonRect.height / 2.0) {
  509. menuBottom = buttonBottom -
  510. buttonRect.height / 2.0 +
  511. itemHeights[selectedIndex] / 2.0;
  512. menuTop = menuBottom - menuHeight;
  513. }
  514. double scrollOffset = 0;
  515. // If all of the menu items will not fit within availableHeight then
  516. // compute the scroll offset that will line the selected menu item up
  517. // with the select item. This is only done when the menu is first
  518. // shown - subsequently we leave the scroll offset where the user left
  519. // it. This scroll offset is only accurate for fixed height menu items
  520. // (the default).
  521. if (preferredMenuHeight > computedMaxHeight) {
  522. // The offset should be zero if the selected item is in view at the beginning
  523. // of the menu. Otherwise, the scroll offset should center the item if possible.
  524. scrollOffset = math.max(0.0, selectedItemOffset - (buttonTop - menuTop));
  525. // If the selected item's scroll offset is greater than the maximum scroll offset,
  526. // set it instead to the maximum allowed scroll offset.
  527. scrollOffset = math.min(scrollOffset, preferredMenuHeight - menuHeight);
  528. }
  529. assert((menuBottom - menuTop - menuHeight).abs() < precisionErrorTolerance);
  530. return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset);
  531. }
  532. }
  533. class _DropdownRoutePage<T> extends StatelessWidget {
  534. const _DropdownRoutePage({
  535. super.key,
  536. required this.route,
  537. required this.constraints,
  538. this.items,
  539. required this.padding,
  540. required this.buttonRect,
  541. required this.selectedIndex,
  542. this.elevation = 8,
  543. required this.capturedThemes,
  544. this.style,
  545. required this.dropdownColor,
  546. required this.enableFeedback,
  547. this.borderRadius,
  548. });
  549. final _DropdownRoute<T> route;
  550. final BoxConstraints constraints;
  551. final List<_MenuItem<T>>? items;
  552. final EdgeInsetsGeometry padding;
  553. final Rect buttonRect;
  554. final int selectedIndex;
  555. final int elevation;
  556. final CapturedThemes capturedThemes;
  557. final TextStyle? style;
  558. final Color? dropdownColor;
  559. final bool enableFeedback;
  560. final BorderRadius? borderRadius;
  561. @override
  562. Widget build(BuildContext context) {
  563. assert(debugCheckHasDirectionality(context));
  564. // Computing the initialScrollOffset now, before the items have been laid
  565. // out. This only works if the item heights are effectively fixed, i.e. either
  566. // DropdownButton.itemHeight is specified or DropdownButton.itemHeight is null
  567. // and all of the items' intrinsic heights are less than kMinInteractiveDimension.
  568. // Otherwise the initialScrollOffset is just a rough approximation based on
  569. // treating the items as if their heights were all equal to kMinInteractiveDimension.
  570. if (route.scrollController == null) {
  571. final _MenuLimits menuLimits =
  572. route.getMenuLimits(buttonRect, constraints.maxHeight, selectedIndex);
  573. route.scrollController =
  574. ScrollController(initialScrollOffset: menuLimits.scrollOffset);
  575. }
  576. final TextDirection? textDirection = Directionality.maybeOf(context);
  577. final Widget menu = _DropdownMenu<T>(
  578. route: route,
  579. padding: padding.resolve(textDirection),
  580. buttonRect: buttonRect,
  581. constraints: constraints,
  582. dropdownColor: dropdownColor,
  583. enableFeedback: enableFeedback,
  584. borderRadius: borderRadius,
  585. );
  586. return MediaQuery.removePadding(
  587. context: context,
  588. removeTop: true,
  589. removeBottom: true,
  590. removeLeft: true,
  591. removeRight: true,
  592. child: Builder(
  593. builder: (BuildContext context) {
  594. return CustomSingleChildLayout(
  595. delegate: _DropdownMenuRouteLayout<T>(
  596. buttonRect: buttonRect,
  597. route: route,
  598. textDirection: textDirection,
  599. ),
  600. child: capturedThemes.wrap(menu),
  601. );
  602. },
  603. ),
  604. );
  605. }
  606. }
  607. // This widget enables _DropdownRoute to look up the sizes of
  608. // each menu item. These sizes are used to compute the offset of the selected
  609. // item so that _DropdownRoutePage can align the vertical center of the
  610. // selected item lines up with the vertical center of the dropdown button,
  611. // as closely as possible.
  612. class _MenuItem<T> extends SingleChildRenderObjectWidget {
  613. const _MenuItem({
  614. super.key,
  615. required this.onLayout,
  616. required this.item,
  617. }) : assert(onLayout != null),
  618. super(child: item);
  619. final ValueChanged<Size> onLayout;
  620. final CustomDropdownMenuItem<T>? item;
  621. @override
  622. RenderObject createRenderObject(BuildContext context) {
  623. return _RenderMenuItem(onLayout);
  624. }
  625. @override
  626. void updateRenderObject(
  627. BuildContext context, covariant _RenderMenuItem renderObject) {
  628. renderObject.onLayout = onLayout;
  629. }
  630. }
  631. class _RenderMenuItem extends RenderProxyBox {
  632. _RenderMenuItem(this.onLayout, [RenderBox? child])
  633. : assert(onLayout != null),
  634. super(child);
  635. ValueChanged<Size> onLayout;
  636. @override
  637. void performLayout() {
  638. super.performLayout();
  639. onLayout(size);
  640. }
  641. }
  642. // The container widget for a menu item created by a [DropdownButton]. It
  643. // provides the default configuration for [DropdownMenuItem]s, as well as a
  644. // [DropdownButton]'s hint and disabledHint widgets.
  645. class _DropdownMenuItemContainer extends StatelessWidget {
  646. /// Creates an item for a dropdown menu.
  647. ///
  648. /// The [child] argument is required.
  649. const _DropdownMenuItemContainer({
  650. super.key,
  651. this.alignment = AlignmentDirectional.centerStart,
  652. required this.child,
  653. }) : assert(child != null);
  654. /// The widget below this widget in the tree.
  655. ///
  656. /// Typically a [Text] widget.
  657. final Widget child;
  658. /// Defines how the item is positioned within the container.
  659. ///
  660. /// This property must not be null. It defaults to [AlignmentDirectional.centerStart].
  661. ///
  662. /// See also:
  663. ///
  664. /// * [Alignment], a class with convenient constants typically used to
  665. /// specify an [AlignmentGeometry].
  666. /// * [AlignmentDirectional], like [Alignment] for specifying alignments
  667. /// relative to text direction.
  668. final AlignmentGeometry alignment;
  669. @override
  670. Widget build(BuildContext context) {
  671. return Container(
  672. constraints: BoxConstraints(minHeight: _kMenuItemHeight),
  673. alignment: alignment,
  674. child: child,
  675. );
  676. }
  677. }
  678. /// An item in a menu created by a [CustomDropdownButton].
  679. ///
  680. /// The type `T` is the type of the value the entry represents. All the entries
  681. /// in a given menu must represent values with consistent types.
  682. class CustomDropdownMenuItem<T> extends _DropdownMenuItemContainer {
  683. /// Creates an item for a dropdown menu.
  684. ///
  685. /// The [child] argument is required.
  686. const CustomDropdownMenuItem({
  687. super.key,
  688. this.onTap,
  689. this.value,
  690. this.enabled = true,
  691. super.alignment,
  692. required super.child,
  693. }) : assert(child != null);
  694. /// Called when the dropdown menu item is tapped.
  695. final VoidCallback? onTap;
  696. /// The value to return if the user selects this menu item.
  697. ///
  698. /// Eventually returned in a call to [CustomDropdownButton.onChanged].
  699. final T? value;
  700. /// Whether or not a user can select this menu item.
  701. ///
  702. /// Defaults to `true`.
  703. final bool enabled;
  704. }
  705. /// An inherited widget that causes any descendant [CustomDropdownButton]
  706. /// widgets to not include their regular underline.
  707. ///
  708. /// This is used by [DataTable] to remove the underline from any
  709. /// [CustomDropdownButton] widgets placed within material data tables, as
  710. /// required by the Material Design specification.
  711. class CustomDropdownButtonHideUnderline extends InheritedWidget {
  712. /// Creates a [CustomDropdownButtonHideUnderline]. A non-null [child] must
  713. /// be given.
  714. const CustomDropdownButtonHideUnderline({
  715. super.key,
  716. required super.child,
  717. }) : assert(child != null);
  718. /// Returns whether the underline of [CustomDropdownButton] widgets should
  719. /// be hidden.
  720. static bool at(BuildContext context) {
  721. return context.dependOnInheritedWidgetOfExactType<
  722. CustomDropdownButtonHideUnderline>() !=
  723. null;
  724. }
  725. @override
  726. bool updateShouldNotify(CustomDropdownButtonHideUnderline oldWidget) => false;
  727. }
  728. /// A Material Design button for selecting from a list of items.
  729. ///
  730. /// A dropdown button lets the user select from a number of items. The button
  731. /// shows the currently selected item as well as an arrow that opens a menu for
  732. /// selecting another item.
  733. ///
  734. /// {@youtube 560 315 https://www.youtube.com/watch?v=ZzQ_PWrFihg}
  735. ///
  736. /// One ancestor must be a [Material] widget and typically this is
  737. /// provided by the app's [Scaffold].
  738. ///
  739. /// The type `T` is the type of the [value] that each dropdown item represents.
  740. /// All the entries in a given menu must represent values with consistent types.
  741. /// Typically, an enum is used. Each [CustomDropdownMenuItem] in [items] must be
  742. /// specialized with that same type argument.
  743. ///
  744. /// The [onChanged] callback should update a state variable that defines the
  745. /// dropdown's value. It should also call [State.setState] to rebuild the
  746. /// dropdown with the new value.
  747. ///
  748. /// {@tool dartpad}
  749. /// This sample shows a [CustomDropdownButton] with a large arrow icon,
  750. /// purple text style, and bold purple underline, whose value is one of "One",
  751. /// "Two", "Free", or "Four".
  752. ///
  753. /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/dropdown_button.png)
  754. ///
  755. /// ** See code in examples/api/lib/material/dropdown/dropdown_button.0.dart **
  756. /// {@end-tool}
  757. ///
  758. /// If the [onChanged] callback is null or the list of [items] is null
  759. /// then the dropdown button will be disabled, i.e. its arrow will be
  760. /// displayed in grey and it will not respond to input. A disabled button
  761. /// will display the [disabledHint] widget if it is non-null. However, if
  762. /// [disabledHint] is null and [hint] is non-null, the [hint] widget will
  763. /// instead be displayed.
  764. ///
  765. /// Requires one of its ancestors to be a [Material] widget.
  766. ///
  767. /// {@youtube 560 315 https://www.youtube.com/watch?v=ZzQ_PWrFihg}
  768. ///
  769. /// See also:
  770. ///
  771. /// * [DropdownButtonFormField], which integrates with the [Form] widget.
  772. /// * [CustomDropdownMenuItem], the class used to represent the [items].
  773. /// * [CustomDropdownButtonHideUnderline], which prevents its descendant dropdown buttons
  774. /// from displaying their underlines.
  775. /// * [CustomElevatedButton], [CustomTextButton], ordinary buttons that trigger a single action.
  776. /// * <https://material.io/design/components/menus.html#dropdown-menu>
  777. class CustomDropdownButton<T> extends StatefulWidget {
  778. /// Creates a dropdown button.
  779. ///
  780. /// The [items] must have distinct values. If [value] isn't null then it
  781. /// must be equal to one of the [CustomDropdownMenuItem] values. If [items] or
  782. /// [onChanged] is null, the button will be disabled, the down arrow
  783. /// will be greyed out.
  784. ///
  785. /// If [value] is null and the button is enabled, [hint] will be displayed
  786. /// if it is non-null.
  787. ///
  788. /// If [value] is null and the button is disabled, [disabledHint] will be displayed
  789. /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed
  790. /// if it is non-null.
  791. ///
  792. /// The [elevation] and [iconSize] arguments must not be null (they both have
  793. /// defaults, so do not need to be specified). The boolean [isDense] and
  794. /// [isExpanded] arguments must not be null.
  795. ///
  796. /// The [autofocus] argument must not be null.
  797. ///
  798. /// The [dropdownColor] argument specifies the background color of the
  799. /// dropdown when it is open. If it is null, the current theme's
  800. /// [ThemeData.canvasColor] will be used instead.
  801. CustomDropdownButton({
  802. super.key,
  803. required this.items,
  804. this.selectedItemBuilder,
  805. this.value,
  806. this.hint,
  807. this.disabledHint,
  808. required this.onChanged,
  809. this.onTap,
  810. this.elevation = 8,
  811. this.style,
  812. this.underline,
  813. this.icon,
  814. this.iconDisabledColor,
  815. this.iconEnabledColor,
  816. this.iconSize = 24.0,
  817. this.isDense = false,
  818. this.isExpanded = false,
  819. this.itemHeight = kMinInteractiveDimension,
  820. this.focusColor,
  821. this.focusNode,
  822. this.autofocus = false,
  823. this.dropdownColor,
  824. this.menuMaxHeight,
  825. this.enableFeedback,
  826. this.alignment = AlignmentDirectional.centerStart,
  827. this.borderRadius,
  828. // When adding new arguments, consider adding similar arguments to
  829. // DropdownButtonFormField.
  830. }) : assert(
  831. items == null ||
  832. items.isEmpty ||
  833. value == null ||
  834. items.where((CustomDropdownMenuItem<T> item) {
  835. return item.value == value;
  836. }).length ==
  837. 1,
  838. "There should be exactly one item with [DropdownButton]'s value: "
  839. '$value. \n'
  840. 'Either zero or 2 or more [DropdownMenuItem]s were detected '
  841. 'with the same value',
  842. ),
  843. assert(elevation != null),
  844. assert(iconSize != null),
  845. assert(isDense != null),
  846. assert(isExpanded != null),
  847. assert(autofocus != null),
  848. _inputDecoration = null,
  849. _isEmpty = false,
  850. _isFocused = false;
  851. CustomDropdownButton._formField({
  852. super.key,
  853. required this.items,
  854. this.selectedItemBuilder,
  855. this.value,
  856. this.hint,
  857. this.disabledHint,
  858. required this.onChanged,
  859. this.onTap,
  860. this.elevation = 8,
  861. this.style,
  862. this.underline,
  863. this.icon,
  864. this.iconDisabledColor,
  865. this.iconEnabledColor,
  866. this.iconSize = 24.0,
  867. this.isDense = false,
  868. this.isExpanded = false,
  869. this.itemHeight = kMinInteractiveDimension,
  870. this.focusColor,
  871. this.focusNode,
  872. this.autofocus = false,
  873. this.dropdownColor,
  874. this.menuMaxHeight,
  875. this.enableFeedback,
  876. this.alignment = AlignmentDirectional.centerStart,
  877. this.borderRadius,
  878. required InputDecoration inputDecoration,
  879. required bool isEmpty,
  880. required bool isFocused,
  881. }) : assert(
  882. items == null ||
  883. items.isEmpty ||
  884. value == null ||
  885. items.where((CustomDropdownMenuItem<T> item) {
  886. return item.value == value;
  887. }).length ==
  888. 1,
  889. "There should be exactly one item with [DropdownButtonFormField]'s value: "
  890. '$value. \n'
  891. 'Either zero or 2 or more [DropdownMenuItem]s were detected '
  892. 'with the same value',
  893. ),
  894. assert(elevation != null),
  895. assert(iconSize != null),
  896. assert(isDense != null),
  897. assert(isExpanded != null),
  898. assert(autofocus != null),
  899. assert(isEmpty != null),
  900. assert(isFocused != null),
  901. _inputDecoration = inputDecoration,
  902. _isEmpty = isEmpty,
  903. _isFocused = isFocused;
  904. /// The list of items the user can select.
  905. ///
  906. /// If the [onChanged] callback is null or the list of items is null
  907. /// then the dropdown button will be disabled, i.e. its arrow will be
  908. /// displayed in grey and it will not respond to input.
  909. final List<CustomDropdownMenuItem<T>>? items;
  910. /// The value of the currently selected [CustomDropdownMenuItem].
  911. ///
  912. /// If [value] is null and the button is enabled, [hint] will be displayed
  913. /// if it is non-null.
  914. ///
  915. /// If [value] is null and the button is disabled, [disabledHint] will be displayed
  916. /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed
  917. /// if it is non-null.
  918. final T? value;
  919. /// A placeholder widget that is displayed by the dropdown button.
  920. ///
  921. /// If [value] is null and the dropdown is enabled ([items] and [onChanged] are non-null),
  922. /// this widget is displayed as a placeholder for the dropdown button's value.
  923. ///
  924. /// If [value] is null and the dropdown is disabled and [disabledHint] is null,
  925. /// this widget is used as the placeholder.
  926. final Widget? hint;
  927. /// A preferred placeholder widget that is displayed when the dropdown is disabled.
  928. ///
  929. /// If [value] is null, the dropdown is disabled ([items] or [onChanged] is null),
  930. /// this widget is displayed as a placeholder for the dropdown button's value.
  931. final Widget? disabledHint;
  932. /// {@template flutter.material.dropdownButton.onChanged}
  933. /// Called when the user selects an item.
  934. ///
  935. /// If the [onChanged] callback is null or the list of [CustomDropdownButton.items]
  936. /// is null then the dropdown button will be disabled, i.e. its arrow will be
  937. /// displayed in grey and it will not respond to input. A disabled button
  938. /// will display the [CustomDropdownButton.disabledHint] widget if it is non-null.
  939. /// If [CustomDropdownButton.disabledHint] is also null but [CustomDropdownButton.hint] is
  940. /// non-null, [CustomDropdownButton.hint] will instead be displayed.
  941. /// {@endtemplate}
  942. final ValueChanged<T?>? onChanged;
  943. /// Called when the dropdown button is tapped.
  944. ///
  945. /// This is distinct from [onChanged], which is called when the user
  946. /// selects an item from the dropdown.
  947. ///
  948. /// The callback will not be invoked if the dropdown button is disabled.
  949. final VoidCallback? onTap;
  950. /// A builder to customize the dropdown buttons corresponding to the
  951. /// [CustomDropdownMenuItem]s in [items].
  952. ///
  953. /// When a [CustomDropdownMenuItem] is selected, the widget that will be displayed
  954. /// from the list corresponds to the [CustomDropdownMenuItem] of the same index
  955. /// in [items].
  956. ///
  957. /// {@tool dartpad}
  958. /// This sample shows a `DropdownButton` with a button with [Text] that
  959. /// corresponds to but is unique from [CustomDropdownMenuItem].
  960. ///
  961. /// ** See code in examples/api/lib/material/dropdown/dropdown_button.selected_item_builder.0.dart **
  962. /// {@end-tool}
  963. ///
  964. /// If this callback is null, the [CustomDropdownMenuItem] from [items]
  965. /// that matches [value] will be displayed.
  966. final DropdownButtonBuilder? selectedItemBuilder;
  967. /// The z-coordinate at which to place the menu when open.
  968. ///
  969. /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12,
  970. /// 16, and 24. See [kElevationToShadow].
  971. ///
  972. /// Defaults to 8, the appropriate elevation for dropdown buttons.
  973. final int elevation;
  974. /// The text style to use for text in the dropdown button and the dropdown
  975. /// menu that appears when you tap the button.
  976. ///
  977. /// To use a separate text style for selected item when it's displayed within
  978. /// the dropdown button, consider using [selectedItemBuilder].
  979. ///
  980. /// {@tool dartpad}
  981. /// This sample shows a `DropdownButton` with a dropdown button text style
  982. /// that is different than its menu items.
  983. ///
  984. /// ** See code in examples/api/lib/material/dropdown/dropdown_button.style.0.dart **
  985. /// {@end-tool}
  986. ///
  987. /// Defaults to the [TextTheme.titleMedium] value of the current
  988. /// [ThemeData.textTheme] of the current [Theme].
  989. final TextStyle? style;
  990. /// The widget to use for drawing the drop-down button's underline.
  991. ///
  992. /// Defaults to a 0.0 width bottom border with color 0xFFBDBDBD.
  993. final Widget? underline;
  994. /// The widget to use for the drop-down button's icon.
  995. ///
  996. /// Defaults to an [Icon] with the [Icons.arrow_drop_down] glyph.
  997. final Widget? icon;
  998. /// The color of any [Icon] descendant of [icon] if this button is disabled,
  999. /// i.e. if [onChanged] is null.
  1000. ///
  1001. /// Defaults to [MaterialColor.shade400] of [Colors.grey] when the theme's
  1002. /// [ThemeData.brightness] is [Brightness.light] and to
  1003. /// [Colors.white10] when it is [Brightness.dark]
  1004. final Color? iconDisabledColor;
  1005. /// The color of any [Icon] descendant of [icon] if this button is enabled,
  1006. /// i.e. if [onChanged] is defined.
  1007. ///
  1008. /// Defaults to [MaterialColor.shade700] of [Colors.grey] when the theme's
  1009. /// [ThemeData.brightness] is [Brightness.light] and to
  1010. /// [Colors.white70] when it is [Brightness.dark]
  1011. final Color? iconEnabledColor;
  1012. /// The size to use for the drop-down button's down arrow icon button.
  1013. ///
  1014. /// Defaults to 24.0.
  1015. final double iconSize;
  1016. /// Reduce the button's height.
  1017. ///
  1018. /// By default this button's height is the same as its menu items' heights.
  1019. /// If isDense is true, the button's height is reduced by about half. This
  1020. /// can be useful when the button is embedded in a container that adds
  1021. /// its own decorations, like [InputDecorator].
  1022. final bool isDense;
  1023. /// Set the dropdown's inner contents to horizontally fill its parent.
  1024. ///
  1025. /// By default this button's inner width is the minimum size of its contents.
  1026. /// If [isExpanded] is true, the inner width is expanded to fill its
  1027. /// surrounding container.
  1028. final bool isExpanded;
  1029. /// If null, then the menu item heights will vary according to each menu item's
  1030. /// intrinsic height.
  1031. ///
  1032. /// The default value is [kMinInteractiveDimension], which is also the minimum
  1033. /// height for menu items.
  1034. ///
  1035. /// If this value is null and there isn't enough vertical room for the menu,
  1036. /// then the menu's initial scroll offset may not align the selected item with
  1037. /// the dropdown button. That's because, in this case, the initial scroll
  1038. /// offset is computed as if all of the menu item heights were
  1039. /// [kMinInteractiveDimension].
  1040. final double? itemHeight;
  1041. /// The color for the button's [Material] when it has the input focus.
  1042. final Color? focusColor;
  1043. /// {@macro flutter.widgets.Focus.focusNode}
  1044. final FocusNode? focusNode;
  1045. /// {@macro flutter.widgets.Focus.autofocus}
  1046. final bool autofocus;
  1047. /// The background color of the dropdown.
  1048. ///
  1049. /// If it is not provided, the theme's [ThemeData.canvasColor] will be used
  1050. /// instead.
  1051. final Color? dropdownColor;
  1052. /// The maximum height of the menu.
  1053. ///
  1054. /// The maximum height of the menu must be at least one row shorter than
  1055. /// the height of the app's view. This ensures that a tappable area
  1056. /// outside of the simple menu is present so the user can dismiss the menu.
  1057. ///
  1058. /// If this property is set above the maximum allowable height threshold
  1059. /// mentioned above, then the menu defaults to being padded at the top
  1060. /// and bottom of the menu by at one menu item's height.
  1061. final double? menuMaxHeight;
  1062. /// Whether detected gestures should provide acoustic and/or haptic feedback.
  1063. ///
  1064. /// For example, on Android a tap will produce a clicking sound and a
  1065. /// long-press will produce a short vibration, when feedback is enabled.
  1066. ///
  1067. /// By default, platform-specific feedback is enabled.
  1068. ///
  1069. /// See also:
  1070. ///
  1071. /// * [Feedback] for providing platform-specific feedback to certain actions.
  1072. final bool? enableFeedback;
  1073. /// Defines how the hint or the selected item is positioned within the button.
  1074. ///
  1075. /// This property must not be null. It defaults to [AlignmentDirectional.centerStart].
  1076. ///
  1077. /// See also:
  1078. ///
  1079. /// * [Alignment], a class with convenient constants typically used to
  1080. /// specify an [AlignmentGeometry].
  1081. /// * [AlignmentDirectional], like [Alignment] for specifying alignments
  1082. /// relative to text direction.
  1083. final AlignmentGeometry alignment;
  1084. /// Defines the corner radii of the menu's rounded rectangle shape.
  1085. final BorderRadius? borderRadius;
  1086. final InputDecoration? _inputDecoration;
  1087. final bool _isEmpty;
  1088. final bool _isFocused;
  1089. @override
  1090. State<CustomDropdownButton<T>> createState() =>
  1091. _CustomDropdownButtonState<T>();
  1092. }
  1093. class _CustomDropdownButtonState<T> extends State<CustomDropdownButton<T>>
  1094. with WidgetsBindingObserver {
  1095. int? _selectedIndex;
  1096. _DropdownRoute<T>? _dropdownRoute;
  1097. Orientation? _lastOrientation;
  1098. FocusNode? _internalNode;
  1099. FocusNode? get focusNode => widget.focusNode ?? _internalNode;
  1100. bool _hasPrimaryFocus = false;
  1101. late Map<Type, Action<Intent>> _actionMap;
  1102. // Only used if needed to create _internalNode.
  1103. FocusNode _createFocusNode() {
  1104. return FocusNode(debugLabel: '${widget.runtimeType}');
  1105. }
  1106. @override
  1107. void initState() {
  1108. super.initState();
  1109. _updateSelectedIndex();
  1110. if (widget.focusNode == null) {
  1111. _internalNode ??= _createFocusNode();
  1112. }
  1113. _actionMap = <Type, Action<Intent>>{
  1114. ActivateIntent: CallbackAction<ActivateIntent>(
  1115. onInvoke: (ActivateIntent intent) => _handleTap(),
  1116. ),
  1117. ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
  1118. onInvoke: (ButtonActivateIntent intent) => _handleTap(),
  1119. ),
  1120. };
  1121. focusNode!.addListener(_handleFocusChanged);
  1122. }
  1123. @override
  1124. void dispose() {
  1125. WidgetsBinding.instance.removeObserver(this);
  1126. _removeDropdownRoute();
  1127. focusNode!.removeListener(_handleFocusChanged);
  1128. _internalNode?.dispose();
  1129. super.dispose();
  1130. }
  1131. void _removeDropdownRoute() {
  1132. _dropdownRoute?._dismiss();
  1133. _dropdownRoute = null;
  1134. _lastOrientation = null;
  1135. }
  1136. void _handleFocusChanged() {
  1137. if (_hasPrimaryFocus != focusNode!.hasPrimaryFocus) {
  1138. setState(() {
  1139. _hasPrimaryFocus = focusNode!.hasPrimaryFocus;
  1140. });
  1141. }
  1142. }
  1143. @override
  1144. void didUpdateWidget(CustomDropdownButton<T> oldWidget) {
  1145. super.didUpdateWidget(oldWidget);
  1146. if (widget.focusNode != oldWidget.focusNode) {
  1147. oldWidget.focusNode?.removeListener(_handleFocusChanged);
  1148. if (widget.focusNode == null) {
  1149. _internalNode ??= _createFocusNode();
  1150. }
  1151. _hasPrimaryFocus = focusNode!.hasPrimaryFocus;
  1152. focusNode!.addListener(_handleFocusChanged);
  1153. }
  1154. _updateSelectedIndex();
  1155. }
  1156. void _updateSelectedIndex() {
  1157. if (widget.items == null ||
  1158. widget.items!.isEmpty ||
  1159. (widget.value == null &&
  1160. widget.items!
  1161. .where((CustomDropdownMenuItem<T> item) =>
  1162. item.enabled && item.value == widget.value)
  1163. .isEmpty)) {
  1164. _selectedIndex = null;
  1165. return;
  1166. }
  1167. assert(widget.items!
  1168. .where(
  1169. (CustomDropdownMenuItem<T> item) => item.value == widget.value)
  1170. .length ==
  1171. 1);
  1172. for (int itemIndex = 0; itemIndex < widget.items!.length; itemIndex++) {
  1173. if (widget.items![itemIndex].value == widget.value) {
  1174. _selectedIndex = itemIndex;
  1175. return;
  1176. }
  1177. }
  1178. }
  1179. TextStyle? get _textStyle =>
  1180. widget.style ?? Theme.of(context).textTheme.titleMedium;
  1181. void _handleTap() {
  1182. final TextDirection? textDirection = Directionality.maybeOf(context);
  1183. final EdgeInsetsGeometry menuMargin =
  1184. ButtonTheme.of(context).alignedDropdown
  1185. ? _kAlignedMenuMargin
  1186. : _kUnalignedMenuMargin;
  1187. final List<_MenuItem<T>> menuItems = <_MenuItem<T>>[
  1188. for (int index = 0; index < widget.items!.length; index += 1)
  1189. _MenuItem<T>(
  1190. item: widget.items![index],
  1191. onLayout: (Size size) {
  1192. // If [_dropdownRoute] is null and onLayout is called, this means
  1193. // that performLayout was called on a _DropdownRoute that has not
  1194. // left the widget tree but is already on its way out.
  1195. //
  1196. // Since onLayout is used primarily to collect the desired heights
  1197. // of each menu item before laying them out, not having the _DropdownRoute
  1198. // collect each item's height to lay out is fine since the route is
  1199. // already on its way out.
  1200. if (_dropdownRoute == null) {
  1201. return;
  1202. }
  1203. _dropdownRoute!.itemHeights[index] = size.height;
  1204. },
  1205. ),
  1206. ];
  1207. final NavigatorState navigator = Navigator.of(context);
  1208. assert(_dropdownRoute == null);
  1209. final RenderBox itemBox = context.findRenderObject()! as RenderBox;
  1210. final Rect itemRect = itemBox.localToGlobal(Offset.zero,
  1211. ancestor: navigator.context.findRenderObject()) &
  1212. itemBox.size;
  1213. _dropdownRoute = _DropdownRoute<T>(
  1214. items: menuItems,
  1215. buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
  1216. padding: _kMenuItemPadding.resolve(textDirection),
  1217. selectedIndex: _selectedIndex ?? 0,
  1218. elevation: widget.elevation,
  1219. capturedThemes:
  1220. InheritedTheme.capture(from: context, to: navigator.context),
  1221. style: _textStyle!,
  1222. barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
  1223. itemHeight: widget.itemHeight,
  1224. dropdownColor: widget.dropdownColor,
  1225. menuMaxHeight: widget.menuMaxHeight,
  1226. enableFeedback: widget.enableFeedback ?? true,
  1227. borderRadius: widget.borderRadius,
  1228. );
  1229. focusNode?.requestFocus();
  1230. navigator
  1231. .push(_dropdownRoute!)
  1232. .then<void>((_DropdownRouteResult<T>? newValue) {
  1233. _removeDropdownRoute();
  1234. if (!mounted || newValue == null) {
  1235. return;
  1236. }
  1237. widget.onChanged?.call(newValue.result);
  1238. });
  1239. widget.onTap?.call();
  1240. }
  1241. // When isDense is true, reduce the height of this button from _kMenuItemHeight to
  1242. // _kDenseButtonHeight, but don't make it smaller than the text that it contains.
  1243. // Similarly, we don't reduce the height of the button so much that its icon
  1244. // would be clipped.
  1245. double get _denseButtonHeight {
  1246. final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
  1247. final double fontSize = _textStyle!.fontSize ??
  1248. Theme.of(context).textTheme.titleMedium!.fontSize!;
  1249. final double scaledFontSize = textScaleFactor * fontSize;
  1250. return math.max(
  1251. scaledFontSize, math.max(widget.iconSize, _kDenseButtonHeight));
  1252. }
  1253. Color get _iconColor {
  1254. // These colors are not defined in the Material Design spec.
  1255. if (_enabled) {
  1256. if (widget.iconEnabledColor != null) {
  1257. return widget.iconEnabledColor!;
  1258. }
  1259. switch (Theme.of(context).brightness) {
  1260. case Brightness.light:
  1261. return Colors.grey.shade700;
  1262. case Brightness.dark:
  1263. return Colors.white70;
  1264. }
  1265. } else {
  1266. if (widget.iconDisabledColor != null) {
  1267. return widget.iconDisabledColor!;
  1268. }
  1269. switch (Theme.of(context).brightness) {
  1270. case Brightness.light:
  1271. return Colors.grey.shade400;
  1272. case Brightness.dark:
  1273. return Colors.white10;
  1274. }
  1275. }
  1276. }
  1277. bool get _enabled =>
  1278. widget.items != null &&
  1279. widget.items!.isNotEmpty &&
  1280. widget.onChanged != null;
  1281. Orientation _getOrientation(BuildContext context) {
  1282. Orientation? result = MediaQuery.maybeOf(context)?.orientation;
  1283. if (result == null) {
  1284. // If there's no MediaQuery, then use the window aspect to determine
  1285. // orientation.
  1286. final Size size = WidgetsBinding.instance.window.physicalSize;
  1287. result = size.width > size.height
  1288. ? Orientation.landscape
  1289. : Orientation.portrait;
  1290. }
  1291. return result;
  1292. }
  1293. @override
  1294. Widget build(BuildContext context) {
  1295. assert(debugCheckHasMaterial(context));
  1296. assert(debugCheckHasMaterialLocalizations(context));
  1297. final Orientation newOrientation = _getOrientation(context);
  1298. _lastOrientation ??= newOrientation;
  1299. if (newOrientation != _lastOrientation) {
  1300. _removeDropdownRoute();
  1301. _lastOrientation = newOrientation;
  1302. }
  1303. // The width of the button and the menu are defined by the widest
  1304. // item and the width of the hint.
  1305. // We should explicitly type the items list to be a list of <Widget>,
  1306. // otherwise, no explicit type adding items maybe trigger a crash/failure
  1307. // when hint and selectedItemBuilder are provided.
  1308. final List<Widget> items = widget.selectedItemBuilder == null
  1309. ? (widget.items != null ? List<Widget>.of(widget.items!) : <Widget>[])
  1310. : List<Widget>.of(widget.selectedItemBuilder!(context));
  1311. int? hintIndex;
  1312. if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
  1313. final Widget displayedHint =
  1314. _enabled ? widget.hint! : widget.disabledHint ?? widget.hint!;
  1315. hintIndex = items.length;
  1316. items.add(DefaultTextStyle(
  1317. style: _textStyle!.copyWith(color: Theme.of(context).hintColor),
  1318. child: IgnorePointer(
  1319. ignoringSemantics: false,
  1320. child: _DropdownMenuItemContainer(
  1321. alignment: widget.alignment,
  1322. child: displayedHint,
  1323. ),
  1324. ),
  1325. ));
  1326. }
  1327. final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
  1328. ? _kAlignedButtonPadding
  1329. : _kUnalignedButtonPadding;
  1330. // If value is null (then _selectedIndex is null) then we
  1331. // display the hint or nothing at all.
  1332. final Widget innerItemsWidget;
  1333. if (items.isEmpty) {
  1334. innerItemsWidget = const SizedBox.shrink();
  1335. } else {
  1336. innerItemsWidget = IndexedStack(
  1337. index: _selectedIndex ?? hintIndex,
  1338. alignment: widget.alignment,
  1339. children: widget.isDense
  1340. ? items
  1341. : items.map((Widget item) {
  1342. return widget.itemHeight != null
  1343. ? SizedBox(height: widget.itemHeight, child: item)
  1344. : Column(
  1345. mainAxisSize: MainAxisSize.min,
  1346. children: <Widget>[item]);
  1347. }).toList(),
  1348. );
  1349. }
  1350. const Icon defaultIcon = Icon(Icons.arrow_drop_down);
  1351. Widget result = DefaultTextStyle(
  1352. style: _enabled
  1353. ? _textStyle!
  1354. : _textStyle!.copyWith(color: Theme.of(context).disabledColor),
  1355. child: Container(
  1356. padding: padding.resolve(Directionality.of(context)),
  1357. height: widget.isDense ? _denseButtonHeight : null,
  1358. child: Row(
  1359. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  1360. mainAxisSize: MainAxisSize.min,
  1361. children: <Widget>[
  1362. if (widget.isExpanded)
  1363. Expanded(child: innerItemsWidget)
  1364. else
  1365. innerItemsWidget,
  1366. IconTheme(
  1367. data: IconThemeData(
  1368. color: _iconColor,
  1369. size: widget.iconSize,
  1370. ),
  1371. child: widget.icon ?? defaultIcon,
  1372. ),
  1373. ],
  1374. ),
  1375. ),
  1376. );
  1377. if (!CustomDropdownButtonHideUnderline.at(context)) {
  1378. final double bottom =
  1379. (widget.isDense || widget.itemHeight == null) ? 0 : 8.s;
  1380. result = Stack(
  1381. children: <Widget>[
  1382. result,
  1383. Positioned(
  1384. left: 0.0,
  1385. right: 0.0,
  1386. bottom: bottom,
  1387. child: widget.underline ??
  1388. Container(
  1389. height: 1.s,
  1390. decoration: const BoxDecoration(
  1391. border: Border(
  1392. bottom: BorderSide(
  1393. color: Color(0xFFBDBDBD),
  1394. width: 0.0,
  1395. ),
  1396. ),
  1397. ),
  1398. ),
  1399. ),
  1400. ],
  1401. );
  1402. }
  1403. final MouseCursor effectiveMouseCursor =
  1404. MaterialStateProperty.resolveAs<MouseCursor>(
  1405. MaterialStateMouseCursor.clickable,
  1406. <MaterialState>{
  1407. if (!_enabled) MaterialState.disabled,
  1408. },
  1409. );
  1410. if (widget._inputDecoration != null) {
  1411. result = InputDecorator(
  1412. decoration: widget._inputDecoration!,
  1413. isEmpty: widget._isEmpty,
  1414. isFocused: widget._isFocused,
  1415. child: result,
  1416. );
  1417. }
  1418. return Semantics(
  1419. button: true,
  1420. child: Actions(
  1421. actions: _actionMap,
  1422. child: InkWell(
  1423. mouseCursor: effectiveMouseCursor,
  1424. onTap: _enabled ? _handleTap : null,
  1425. canRequestFocus: _enabled,
  1426. borderRadius: widget.borderRadius,
  1427. focusNode: focusNode,
  1428. autofocus: widget.autofocus,
  1429. focusColor: widget.focusColor ?? Theme.of(context).focusColor,
  1430. enableFeedback: false,
  1431. child: result,
  1432. ),
  1433. ),
  1434. );
  1435. }
  1436. }
  1437. /// A [FormField] that contains a [CustomDropdownButton].
  1438. ///
  1439. /// This is a convenience widget that wraps a [CustomDropdownButton] widget in a
  1440. /// [FormField].
  1441. ///
  1442. /// A [Form] ancestor is not required. The [Form] simply makes it easier to
  1443. /// save, reset, or validate multiple fields at once. To use without a [Form],
  1444. /// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to
  1445. /// save or reset the form field.
  1446. ///
  1447. /// See also:
  1448. ///
  1449. /// * [CustomDropdownButton], which is the underlying text field without the [Form]
  1450. /// integration.
  1451. class DropdownButtonFormField<T> extends FormField<T> {
  1452. /// Creates a [CustomDropdownButton] widget that is a [FormField], wrapped in an
  1453. /// [InputDecorator].
  1454. ///
  1455. /// For a description of the `onSaved`, `validator`, or `autovalidateMode`
  1456. /// parameters, see [FormField]. For the rest (other than [decoration]), see
  1457. /// [CustomDropdownButton].
  1458. ///
  1459. /// The `items`, `elevation`, `iconSize`, `isDense`, `isExpanded`,
  1460. /// `autofocus`, and `decoration` parameters must not be null.
  1461. DropdownButtonFormField({
  1462. super.key,
  1463. required List<CustomDropdownMenuItem<T>>? items,
  1464. DropdownButtonBuilder? selectedItemBuilder,
  1465. T? value,
  1466. Widget? hint,
  1467. Widget? disabledHint,
  1468. required this.onChanged,
  1469. VoidCallback? onTap,
  1470. int elevation = 8,
  1471. TextStyle? style,
  1472. Widget? icon,
  1473. Color? iconDisabledColor,
  1474. Color? iconEnabledColor,
  1475. double iconSize = 24.0,
  1476. bool isDense = true,
  1477. bool isExpanded = false,
  1478. double? itemHeight,
  1479. Color? focusColor,
  1480. FocusNode? focusNode,
  1481. bool autofocus = false,
  1482. Color? dropdownColor,
  1483. InputDecoration? decoration,
  1484. super.onSaved,
  1485. super.validator,
  1486. AutovalidateMode? autovalidateMode,
  1487. double? menuMaxHeight,
  1488. bool? enableFeedback,
  1489. AlignmentGeometry alignment = AlignmentDirectional.centerStart,
  1490. BorderRadius? borderRadius,
  1491. // When adding new arguments, consider adding similar arguments to
  1492. // DropdownButton.
  1493. }) : assert(
  1494. items == null ||
  1495. items.isEmpty ||
  1496. value == null ||
  1497. items.where((CustomDropdownMenuItem<T> item) {
  1498. return item.value == value;
  1499. }).length ==
  1500. 1,
  1501. "There should be exactly one item with [DropdownButton]'s value: "
  1502. '$value. \n'
  1503. 'Either zero or 2 or more [DropdownMenuItem]s were detected '
  1504. 'with the same value',
  1505. ),
  1506. assert(elevation != null),
  1507. assert(iconSize != null),
  1508. assert(isDense != null),
  1509. assert(isExpanded != null),
  1510. assert(autofocus != null),
  1511. decoration = decoration ?? InputDecoration(focusColor: focusColor),
  1512. super(
  1513. initialValue: value,
  1514. autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled,
  1515. builder: (FormFieldState<T> field) {
  1516. final _DropdownButtonFormFieldState<T> state =
  1517. field as _DropdownButtonFormFieldState<T>;
  1518. final InputDecoration decorationArg =
  1519. decoration ?? InputDecoration(focusColor: focusColor);
  1520. final InputDecoration effectiveDecoration =
  1521. decorationArg.applyDefaults(
  1522. Theme.of(field.context).inputDecorationTheme,
  1523. );
  1524. final bool showSelectedItem = items != null &&
  1525. items
  1526. .where((CustomDropdownMenuItem<T> item) =>
  1527. item.value == state.value)
  1528. .isNotEmpty;
  1529. bool isHintOrDisabledHintAvailable() {
  1530. final bool isDropdownDisabled =
  1531. onChanged == null || (items == null || items.isEmpty);
  1532. if (isDropdownDisabled) {
  1533. return hint != null || disabledHint != null;
  1534. } else {
  1535. return hint != null;
  1536. }
  1537. }
  1538. final bool isEmpty =
  1539. !showSelectedItem && !isHintOrDisabledHintAvailable();
  1540. // An unfocusable Focus widget so that this widget can detect if its
  1541. // descendants have focus or not.
  1542. return Focus(
  1543. canRequestFocus: false,
  1544. skipTraversal: true,
  1545. child: Builder(builder: (BuildContext context) {
  1546. return CustomDropdownButtonHideUnderline(
  1547. child: CustomDropdownButton<T>._formField(
  1548. items: items,
  1549. selectedItemBuilder: selectedItemBuilder,
  1550. value: state.value,
  1551. hint: hint,
  1552. disabledHint: disabledHint,
  1553. onChanged: onChanged == null ? null : state.didChange,
  1554. onTap: onTap,
  1555. elevation: elevation,
  1556. style: style,
  1557. icon: icon,
  1558. iconDisabledColor: iconDisabledColor,
  1559. iconEnabledColor: iconEnabledColor,
  1560. iconSize: iconSize,
  1561. isDense: isDense,
  1562. isExpanded: isExpanded,
  1563. itemHeight: itemHeight,
  1564. focusColor: focusColor,
  1565. focusNode: focusNode,
  1566. autofocus: autofocus,
  1567. dropdownColor: dropdownColor,
  1568. menuMaxHeight: menuMaxHeight,
  1569. enableFeedback: enableFeedback,
  1570. alignment: alignment,
  1571. borderRadius: borderRadius,
  1572. inputDecoration: effectiveDecoration.copyWith(
  1573. errorText: field.errorText),
  1574. isEmpty: isEmpty,
  1575. isFocused: Focus.of(context).hasFocus,
  1576. ),
  1577. );
  1578. }),
  1579. );
  1580. },
  1581. );
  1582. /// {@macro flutter.material.dropdownButton.onChanged}
  1583. final ValueChanged<T?>? onChanged;
  1584. /// The decoration to show around the dropdown button form field.
  1585. ///
  1586. /// By default, draws a horizontal line under the dropdown button field but
  1587. /// can be configured to show an icon, label, hint text, and error text.
  1588. ///
  1589. /// If not specified, an [InputDecorator] with the `focusColor` set to the
  1590. /// supplied `focusColor` (if any) will be used.
  1591. final InputDecoration decoration;
  1592. @override
  1593. FormFieldState<T> createState() => _DropdownButtonFormFieldState<T>();
  1594. }
  1595. class _DropdownButtonFormFieldState<T> extends FormFieldState<T> {
  1596. @override
  1597. void didChange(T? value) {
  1598. super.didChange(value);
  1599. final DropdownButtonFormField<T> dropdownButtonFormField =
  1600. widget as DropdownButtonFormField<T>;
  1601. assert(dropdownButtonFormField.onChanged != null);
  1602. dropdownButtonFormField.onChanged!(value);
  1603. }
  1604. @override
  1605. void didUpdateWidget(DropdownButtonFormField<T> oldWidget) {
  1606. super.didUpdateWidget(oldWidget);
  1607. if (oldWidget.initialValue != widget.initialValue) {
  1608. setValue(widget.initialValue);
  1609. }
  1610. }
  1611. }