diff --git a/examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart b/examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart new file mode 100644 index 0000000000..a4c77c6244 --- /dev/null +++ b/examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart @@ -0,0 +1,91 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for the [DropdownMenuEntry] `labelWidget` property. + +enum ColorItem { + blue('Blue', Colors.blue), + pink('Pink', Colors.pink), + green('Green', Colors.green), + yellow('Yellow', Colors.yellow), + grey('Grey', Colors.grey); + + const ColorItem(this.label, this.color); + final String label; + final Color color; +} + +class DropdownMenuEntryLabelWidgetExample extends StatefulWidget { + const DropdownMenuEntryLabelWidgetExample({ super.key }); + + @override + State createState() => _DropdownMenuEntryLabelWidgetExampleState(); +} + +class _DropdownMenuEntryLabelWidgetExampleState extends State { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Created by Google Bard from 'create a lyrical phrase of about 25 words that begins with "is a color"'. + const String longText = 'is a color that sings of hope, A hue that shines like gold. It is the color of dreams, A shade that never grows old.'; + + return Scaffold( + body: Center( + child: DropdownMenu( + width: 300, + controller: controller, + initialSelection: ColorItem.green, + label: const Text('Color'), + onSelected: (ColorItem? color) { + print('Selected $color'); + }, + dropdownMenuEntries: ColorItem.values.map>((ColorItem item) { + final String labelText = '${item.label} $longText\n'; + return DropdownMenuEntry( + value: item, + label: labelText, + // Try commenting the labelWidget out or changing + // the labelWidget's Text parameters. + labelWidget: Text( + labelText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + ); + } +} + +class DropdownMenuEntryLabelWidgetExampleApp extends StatelessWidget { + const DropdownMenuEntryLabelWidgetExampleApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: DropdownMenuEntryLabelWidgetExample(), + ); + } +} + +void main() { + runApp(const DropdownMenuEntryLabelWidgetExampleApp()); +} diff --git a/examples/api/test/material/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart b/examples/api/test/material/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart new file mode 100644 index 0000000000..773c991e9c --- /dev/null +++ b/examples/api/test/material/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DropdownEntryLabelWidget appears', (WidgetTester tester) async { + await tester.pumpWidget( + const example.DropdownMenuEntryLabelWidgetExampleApp(), + ); + + const String longText = 'is a color that sings of hope, A hue that shines like gold. It is the color of dreams, A shade that never grows old.'; + Finder findMenuItemText(String label) { + final String labelText = '$label $longText\n'; + return find.descendant( + of: find.widgetWithText(MenuItemButton, labelText), + matching: find.byType(Text), + ).last; + } + + // Open the menu + await tester.tap(find.byType(TextField)); + expect(findMenuItemText('Blue'), findsOneWidget); + expect(findMenuItemText('Pink'), findsOneWidget); + expect(findMenuItemText('Green'), findsOneWidget); + expect(findMenuItemText('Yellow'), findsOneWidget); + expect(findMenuItemText('Grey'), findsOneWidget); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index 27baf5951b..3beb00d3dd 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -44,6 +44,7 @@ class DropdownMenuEntry { const DropdownMenuEntry({ required this.value, required this.label, + this.labelWidget, this.leadingIcon, this.trailingIcon, this.enabled = true, @@ -58,6 +59,17 @@ class DropdownMenuEntry { /// The label displayed in the center of the menu item. final String label; + /// Overrides the default label widget which is `Text(label)`. + /// + /// {@tool dartpad} + /// This sample shows how to override the default label [Text] + /// widget with one that forces the menu entry to appear on one line + /// by specifying [Text.maxLines] and [Text.overflow]. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart ** + /// {@end-tool} + final Widget? labelWidget; + /// An optional icon to display before the label. final Widget? leadingIcon; @@ -441,6 +453,15 @@ class _DropdownMenuState extends State> { final Color focusedBackgroundColor = effectiveStyle.foregroundColor?.resolve({MaterialState.focused}) ?? Theme.of(context).colorScheme.onSurface; + Widget label = entry.labelWidget ?? Text(entry.label); + if (widget.width != null) { + final double horizontalPadding = padding + _kDefaultHorizontalPadding; + label = ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.width! - horizontalPadding), + child: label, + ); + } + // Simulate the focused state because the text field should always be focused // during traversal. If the menu item has a custom foreground color, the "focused" // color will also change to foregroundColor.withOpacity(0.12). @@ -450,7 +471,7 @@ class _DropdownMenuState extends State> { ) : effectiveStyle; - final MenuItemButton menuItemButton = MenuItemButton( + final Widget menuItemButton = MenuItemButton( key: enableScrollToHighlight ? buttonItemKeys[i] : null, style: effectiveStyle, leadingIcon: entry.leadingIcon, @@ -465,7 +486,7 @@ class _DropdownMenuState extends State> { } : null, requestFocusOnHover: false, - child: Text(entry.label), + child: label, ); result.add(menuItemButton); } diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index 5d8a2c5314..a499bcd262 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + const String longText = 'one two three four five six seven eight nine ten eleven twelve'; final List> menuChildren = >[]; for (final TestMenu value in TestMenu.values) { @@ -1571,6 +1572,114 @@ void main() { expect(material.textStyle?.wordSpacing, menuItemTextThemeStyle.wordSpacing); expect(material.textStyle?.decoration, menuItemTextThemeStyle.decoration); }); + + testWidgets('DropdownMenuEntries do not overflow when width is specified', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/126882 + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu( + controller: controller, + width: 100, + dropdownMenuEntries: TestMenu.values.map>((TestMenu item) { + return DropdownMenuEntry( + value: item, + label: '${item.label} $longText', + ); + }).toList(), + ), + ), + ), + ); + + // Opening the width=100 menu should not crash. + await tester.tap(find.byType(DropdownMenu)); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + Finder findMenuItemText(String label) { + final String labelText = '$label $longText'; + return find.descendant( + of: find.widgetWithText(MenuItemButton, labelText), + matching: find.byType(Text), + ).last; + } + + // Actual size varies a little on web platforms. + final Matcher closeTo300 = closeTo(300, 0.25); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo300); + + await tester.tap(findMenuItemText('Item 0')); + await tester.pumpAndSettle(); + expect(controller.text, 'Item 0 $longText'); + }); + + testWidgets('DropdownMenuEntry.labelWidget is Text that specifies maxLines 1 or 2', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/126882 + final TextEditingController controller = TextEditingController(); + + Widget buildFrame({ required int maxLines }) { + return MaterialApp( + home: Scaffold( + body: DropdownMenu( + key: ValueKey(maxLines), + controller: controller, + width: 100, + dropdownMenuEntries: TestMenu.values.map>((TestMenu item) { + return DropdownMenuEntry( + value: item, + label: '${item.label} $longText', + labelWidget: Text('${item.label} $longText', maxLines: maxLines), + ); + }).toList(), + ), + ) + ); + } + + Finder findMenuItemText(String label) { + final String labelText = '$label $longText'; + return find.descendant( + of: find.widgetWithText(MenuItemButton, labelText), + matching: find.byType(Text), + ).last; + } + + await tester.pumpWidget(buildFrame(maxLines: 1)); + await tester.tap(find.byType(DropdownMenu)); + + // Actual size varies a little on web platforms. + final Matcher closeTo20 = closeTo(20, 0.05); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo20); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(controller.text, ''); // nothing selected + + await tester.pumpWidget(buildFrame(maxLines: 2)); + await tester.tap(find.byType(DropdownMenu)); + + // Actual size varies a little on web platforms. + final Matcher closeTo40 = closeTo(40, 0.05); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo40); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(controller.text, ''); // nothing selected + }); } enum TestMenu {