Unverified Commit 12acff81 authored by Qun Cheng's avatar Qun Cheng Committed by GitHub

`DropdownMenu` can be expanded to its parent size (#129753)

Fixes #125199

This PR is to add a new property `expandedInsets` so that the `DropdownMenu` can be expandable and has some margins around.

<details><summary>Example: Setting `expandedInsets` to `EdgeInsets.zero`</summary>

```dart
import 'package:flutter/material.dart';

void main() => runApp(const DropdownMenuExample());

class DropdownMenuExample extends StatefulWidget {
  const DropdownMenuExample({super.key});

  @override
  State<DropdownMenuExample> createState() => _DropdownMenuExampleState();
}

class _DropdownMenuExampleState extends State<DropdownMenuExample> {
  final TextEditingController colorController = TextEditingController();
  ColorLabel? selectedColor;

  @override
  Widget build(BuildContext context) {
    final List<DropdownMenuEntry<ColorLabel>> colorEntries = <DropdownMenuEntry<ColorLabel>>[];
    for (final ColorLabel color in ColorLabel.values) {
      colorEntries.add(
        DropdownMenuEntry<ColorLabel>(value: color, label: color.label, enabled: color.label != 'Grey'),
      );
    }

    return MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.green,
      ),
      home: Scaffold(
        body: Center(
          child: Container(
            width: 500,
            height: 500,
            color: Colors.orange,
            child: DropdownMenu<ColorLabel>(
              expandedInsets: EdgeInsets.zero,
              inputDecorationTheme: const InputDecorationTheme(
                filled: true,
                fillColor: Colors.white,
                border: OutlineInputBorder(),
              ),
              controller: colorController,
              dropdownMenuEntries: colorEntries,
              onSelected: (ColorLabel? color) {
                setState(() {
                  selectedColor = color;
                });
              },
              // expandedInsets: EdgeInsets.only(left: 35.0, right: 20.0, top: 80),
            ),
          ),
        ),
      ),
    );
  }
}

enum ColorLabel {
  blue('Blue', Colors.blue),
  pink('Pink', Colors.pink),
  green('Green', Colors.green),
  yellow('Yellow', Colors.yellow),
  grey('Grey', Colors.grey);

  const ColorLabel(this.label, this.color);
  final String label;
  final Color color;
}
```

<img width="500" alt="Screenshot 2023-06-28 at 11 33 20 PM" src="https://github.com/flutter/flutter/assets/36861262/e703f8a2-6e7c-45a0-86cf-d96da6dc157a">

</details>
parent 2009f32e
......@@ -139,6 +139,7 @@ class DropdownMenu<T> extends StatefulWidget {
this.initialSelection,
this.onSelected,
this.requestFocusOnTap,
this.expandedInsets,
required this.dropdownMenuEntries,
});
......@@ -277,6 +278,21 @@ class DropdownMenu<T> extends StatefulWidget {
/// contain space for padding.
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
/// Defines the menu text field's width to be equal to its parent's width
/// plus the horizontal width of the specified insets.
///
/// If this property is null, the width of the text field will be determined
/// by the width of menu items or [DropdownMenu.width]. If this property is not null,
/// the text field's width will match the parent's width plus the specified insets.
/// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same
/// as its parent's width.
///
/// The [expandedInsets]' top and bottom are ignored, only its left and right
/// properties are used.
///
/// Defaults to null.
final EdgeInsets? expandedInsets;
@override
State<DropdownMenu<T>> createState() => _DropdownMenuState<T>();
}
......@@ -549,6 +565,108 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
final MouseCursor effectiveMouseCursor = canRequestFocus() ? SystemMouseCursors.text : SystemMouseCursors.click;
Widget menuAnchor = MenuAnchor(
style: effectiveMenuStyle,
controller: _controller,
menuChildren: menu,
crossAxisUnconstrained: false,
builder: (BuildContext context, MenuController controller, Widget? child) {
assert(_initialMenu != null);
final Widget trailingButton = Padding(
padding: const EdgeInsets.all(4.0),
child: IconButton(
isSelected: controller.isOpen,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: () {
handlePressed(controller);
},
),
);
final Widget leadingButton = Padding(
padding: const EdgeInsets.all(8.0),
child: widget.leadingIcon ?? const SizedBox()
);
final Widget textField = TextField(
key: _anchorKey,
mouseCursor: effectiveMouseCursor,
canRequestFocus: canRequestFocus(),
enableInteractiveSelection: canRequestFocus(),
textAlignVertical: TextAlignVertical.center,
style: effectiveTextStyle,
controller: _textEditingController,
onEditingComplete: () {
if (currentHighlight != null) {
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
if (entry.enabled) {
_textEditingController.text = entry.label;
_textEditingController.selection =
TextSelection.collapsed(offset: _textEditingController.text.length);
widget.onSelected?.call(entry.value);
}
} else {
widget.onSelected?.call(null);
}
if (!widget.enableSearch) {
currentHighlight = null;
}
if (_textEditingController.text.isNotEmpty) {
controller.close();
}
},
onTap: () {
handlePressed(controller);
},
onChanged: (String text) {
controller.open();
setState(() {
filteredEntries = widget.dropdownMenuEntries;
_enableFilter = widget.enableFilter;
});
},
decoration: InputDecoration(
enabled: widget.enabled,
label: widget.label,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.leadingIcon != null ? Container(
key: _leadingKey,
child: widget.leadingIcon
) : null,
suffixIcon: trailingButton,
).applyDefaults(effectiveInputDecorationTheme)
);
if (widget.expandedInsets != null) {
// If [expandedInsets] is not null, the width of the text field should depend
// on its parent width. So we don't need to use `_DropdownMenuBody` to
// calculate the children's width.
return textField;
}
return _DropdownMenuBody(
width: widget.width,
children: <Widget>[
textField,
for (final Widget item in _initialMenu!) item,
trailingButton,
leadingButton,
],
);
},
);
if (widget.expandedInsets != null) {
menuAnchor = Container(
alignment: AlignmentDirectional.topStart,
padding: widget.expandedInsets?.copyWith(top: 0.0, bottom: 0.0),
child: menuAnchor,
);
}
return Shortcuts(
shortcuts: _kMenuTraversalShortcuts,
child: Actions(
......@@ -560,90 +678,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
onInvoke: handleDownKeyInvoke,
),
},
child: MenuAnchor(
style: effectiveMenuStyle,
controller: _controller,
menuChildren: menu,
crossAxisUnconstrained: false,
builder: (BuildContext context, MenuController controller, Widget? child) {
assert(_initialMenu != null);
final Widget trailingButton = Padding(
padding: const EdgeInsets.all(4.0),
child: IconButton(
isSelected: controller.isOpen,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: () {
handlePressed(controller);
},
),
);
final Widget leadingButton = Padding(
padding: const EdgeInsets.all(8.0),
child: widget.leadingIcon ?? const SizedBox()
);
return _DropdownMenuBody(
width: widget.width,
children: <Widget>[
TextField(
key: _anchorKey,
mouseCursor: effectiveMouseCursor,
canRequestFocus: canRequestFocus(),
enableInteractiveSelection: canRequestFocus(),
textAlignVertical: TextAlignVertical.center,
style: effectiveTextStyle,
controller: _textEditingController,
onEditingComplete: () {
if (currentHighlight != null) {
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
if (entry.enabled) {
_textEditingController.text = entry.label;
_textEditingController.selection =
TextSelection.collapsed(offset: _textEditingController.text.length);
widget.onSelected?.call(entry.value);
}
} else {
widget.onSelected?.call(null);
}
if (!widget.enableSearch) {
currentHighlight = null;
}
if (_textEditingController.text.isNotEmpty) {
controller.close();
}
},
onTap: () {
handlePressed(controller);
},
onChanged: (String text) {
controller.open();
setState(() {
filteredEntries = widget.dropdownMenuEntries;
_enableFilter = widget.enableFilter;
});
},
decoration: InputDecoration(
enabled: widget.enabled,
label: widget.label,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.leadingIcon != null ? Container(
key: _leadingKey,
child: widget.leadingIcon
) : null,
suffixIcon: trailingButton,
).applyDefaults(effectiveInputDecorationTheme)
),
for (final Widget c in _initialMenu!) c,
trailingButton,
leadingButton,
],
);
},
),
child: menuAnchor,
),
);
}
......
......@@ -216,6 +216,70 @@ void main() {
expect(box.size.width, customWidth);
});
testWidgets('The width of MenuAnchor respects MenuAnchor.expandedInsets', (WidgetTester tester) async {
const double parentWidth = 500.0;
final List<DropdownMenuEntry<ShortMenu>> shortMenuItems = <DropdownMenuEntry<ShortMenu>>[];
for (final ShortMenu value in ShortMenu.values) {
final DropdownMenuEntry<ShortMenu> entry = DropdownMenuEntry<ShortMenu>(value: value, label: value.label);
shortMenuItems.add(entry);
}
Widget buildMenuAnchor({EdgeInsets? expandedInsets}) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
width: parentWidth,
height: parentWidth,
child: DropdownMenu<ShortMenu>(
expandedInsets: expandedInsets,
dropdownMenuEntries: shortMenuItems,
),
),
),
);
}
// By default, the width of the text field is determined by the menu children.
await tester.pumpWidget(buildMenuAnchor());
RenderBox box = tester.firstRenderObject(find.byType(TextField));
expect(box.size.width, 136.0);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
Size buttonSize = tester.getSize(find.widgetWithText(MenuItemButton, 'I0').hitTestable());
expect(buttonSize.width, 136.0);
// If expandedInsets is EdgeInsets.zero, the width should be the same as its parent.
await tester.pumpWidget(Container());
await tester.pumpWidget(buildMenuAnchor(expandedInsets: EdgeInsets.zero));
box = tester.firstRenderObject(find.byType(TextField));
expect(box.size.width, parentWidth);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
buttonSize = tester.getSize(find.widgetWithText(MenuItemButton, 'I0'));
expect(buttonSize.width, parentWidth);
// If expandedInsets is not zero, the width of the text field should be adjusted
// based on the EdgeInsets.left and EdgeInsets.right. The top and bottom values
// will be ignored.
await tester.pumpWidget(Container());
await tester.pumpWidget(buildMenuAnchor(expandedInsets: const EdgeInsets.only(left: 35.0, top: 50.0, right: 20.0)));
box = tester.firstRenderObject(find.byType(TextField));
expect(box.size.width, parentWidth - 35.0 - 20.0);
final Rect containerRect = tester.getRect(find.byType(SizedBox).first);
final Rect dropdownMenuRect = tester.getRect(find.byType(TextField));
expect(dropdownMenuRect.top, containerRect.top);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
buttonSize = tester.getSize(find.widgetWithText(MenuItemButton, 'I0'));
expect(buttonSize.width, parentWidth - 35.0 - 20.0);
});
testWidgets('The menuHeight property can be used to show a shorter scrollable menu list instead of the complete list',
(WidgetTester tester) async {
final ThemeData themeData = ThemeData();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment