Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 106 additions & 43 deletions packages/flutter/lib/src/material/dropdown_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ typedef FilterCallback<T> =
/// Used by [DropdownMenu.searchCallback].
typedef SearchCallback<T> = int? Function(List<DropdownMenuEntry<T>> entries, String query);

/// The type of builder function used by [DropdownMenu.decorationBuilder] to
/// build the [InputDecoration] passed to the inner text field.
///
/// The `context` is the context that the decoration is being built in.
///
/// The `controller` is the [MenuController] that can be used to open and close
/// the menu with and query the current state.
typedef DropdownMenuDecorationBuilder =
InputDecoration Function(BuildContext context, MenuController controller);

const double _kMinimumWidth = 112.0;

const double _kDefaultHorizontalPadding = 12.0;
Expand Down Expand Up @@ -182,6 +192,7 @@ class DropdownMenu<T extends Object> extends StatefulWidget {
this.textAlign = TextAlign.start,
// TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized.
Object? inputDecorationTheme,
this.decorationBuilder,
this.menuStyle,
this.controller,
this.initialSelection,
Expand All @@ -207,6 +218,10 @@ class DropdownMenu<T extends Object> extends StatefulWidget {
inputDecorationTheme is InputDecorationThemeData),
),
assert(trailingIconFocusNode == null || showTrailingIcon),
assert(
decorationBuilder == null ||
(label == null && hintText == null && helperText == null && errorText == null),
),
_inputDecorationTheme = inputDecorationTheme;

/// Determine if the [DropdownMenu] is enabled.
Expand Down Expand Up @@ -369,6 +384,23 @@ class DropdownMenu<T extends Object> extends StatefulWidget {

final Object? _inputDecorationTheme;

/// The builder function used to create the [InputDecoration] passed to the text field.
///
/// If a value is provided for this property and the resulting [InputDecoration.suffixIcon]
/// is null, a default [IconButton] is assigned as the suffix icon. This button's icon will
/// use [trailingIcon] and [selectedTrailingIcon] if those are explicitly defined; otherwise,
/// it defaults to [Icons.arrow_drop_down] for the collapsed state and [Icons.arrow_drop_up]
/// for the expanded state.
///
/// If null, the default builder creates a decoration where:
/// - [InputDecoration.label] is set to [label].
/// - [InputDecoration.hintText] is set to [hintText].
/// - [InputDecoration.helperText] is set to [helperText].
/// - [InputDecoration.errorText] is set to [errorText].
/// - [InputDecoration.prefixIcon] is set to [leadingIcon].
/// - [InputDecoration.suffixIcon] is set to an [IconButton] which uses [trailingIcon] and [selectedTrailingIcon] if defined, or [Icons.arrow_drop_down] and [Icons.arrow_drop_up] otherwise.
final DropdownMenuDecorationBuilder? decorationBuilder;

/// The [MenuStyle] that defines the visual attributes of the menu.
///
/// The default width of the menu is set to the width of the text field.
Expand Down Expand Up @@ -1132,38 +1164,26 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
crossAxisUnconstrained: false,
builder: (BuildContext context, MenuController controller, Widget? child) {
assert(_initialMenu != null);
final bool isCollapsed = widget.inputDecorationTheme?.isCollapsed ?? false;
final Widget trailingButton = widget.showTrailingIcon
? Padding(
padding: isCollapsed ? EdgeInsets.zero : const EdgeInsets.all(4.0),
child: ExcludeSemantics(
// When the text field is treated as a button (i.e., it can
// not be focused), the trailing button should become part of
// the text field button by excluding semantics. Otherwise,
// it will inappropriately announce whether this icon button
// is selected or not.
excluding: !canRequestFocus(),
child: IconButton(
focusNode: _trailingIconButtonFocusNode,
isSelected: controller.isOpen,
constraints: widget.inputDecorationTheme?.suffixIconConstraints,
padding: isCollapsed ? EdgeInsets.zero : null,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: !widget.enabled
? null
: () {
handlePressed(controller);
},
),
),
)
: const SizedBox.shrink();

final Widget leadingButton = Padding(
padding: const EdgeInsets.all(8.0),
child: widget.leadingIcon ?? const SizedBox.shrink(),
final DropdownMenuDecorationBuilder decorationBuilder =
widget.decorationBuilder ?? _buildDefaultDecoration;
InputDecoration decoration = decorationBuilder(context, controller);
// If no suffixIcon is provided, the default IconButton is used for convenience.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think if there will be a case where the user doesn't want the sufficIcon at all? I'm thinking whether we should still give it a default value when suffixIcon is null.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting.
The property DropdownMenu.showTrailingIcon was added some time ago in #167782 for that purpose.

Because DropdownMenu.decorationBuilder is new we can decide to use a different solution. Nonetheless, If we don't add a default suffix when suffixIcon is null, most of the users will have to provide their implementation or we have to offer them an API to get the default Icon. It seems to add complexity for a use case which is somewhat specific.

Copy link
Contributor

@Gustl22 Gustl22 Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd propose to always add the default icon, also with a custom decoration (when the user adds an additinal trailing icon, then keep it in a Row). And the user can still disable it via the property showTrailingIcon. That way the dev does not need to implement it, e.g. also with providing more additional trailing actions, but still has the opportunity to remove it.
That way it has a purpose in all cases, while providing maximum flexibility and minimal effort for the dev.

The other way around: in the current implementation, if the user defines its own trailing icon, the showTrailingIcon does not have any effect, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd propose to always add the default icon, also with a custom decoration (when the user adds an additinal trailing icon, then keep it in a Row)

Adding a Row fits specific use cases and for these use cases it might be better for us to expose the default button construction if you think this would be interesting and let users add a Row (or something else) if needed (that way they can control padding for instance or anything matching their design).
My reasonning is to remove as much as possible custom rendering logic outside of DropdownMenu. In that particular case, InputDecorator already implements APIs that comply with M3 spec (padding, defaults colors, borders, etc) and offer flexibility (InputDecoration.suffix, InputDecoration.suffixIcon, etc).

The other way around: in the current implementation, if the user defines its own trailing icon, the showTrailingIcon does not have any effect, right?

Yes. It surely should be documented (in fact the best move would probably to rename showTrailingIcon to showDefaultTrailingIcon in that case). We can also decide to ignore InputDecoration.suffixIcon if showTrailingIcon is false, it might be easier to document/understand.

Copy link
Contributor

@Gustl22 Gustl22 Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a Row fits specific use cases and for these use cases it might be better for us to expose the default button construction if you think this would be interesting and let users add a Row

Good point.
Of course the Row was just meant as an internal helper to still be able to access the default trailing icon. But yes, it would then only serve in that (in my opinion most common) use case, and shrinks flexibility how (e.g. in which order) to use it.

I personally don't need the default trailing button, as the fuctionality can easily be recreated with the controller. Not sure if folks would notice / search for the constructor of the default icon.

Yes. It surely should be documented (in fact the best move would probably to rename showTrailingIcon to showDefaultTrailingIcon in that case). We can also decide to ignore InputDecoration.suffixIcon if showTrailingIcon is false, it might be easier to document/understand.

Sounds reasonable. Happy with both variants :)

Btw thank you for your efforts!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when suffixIcon is null, most of the users will have to provide their implementation or we have to offer them an API to get the default Icon. It seems to add complexity for a use case which is somewhat specific.

I see, it makes sense to me.

if (decoration.suffixIcon == null) {
decoration = decoration.copyWith(
suffixIcon: _buildDefaultSuffixIcon(context, controller),
);
}
final InputDecoration effectiveDecoration = decoration.applyDefaults(
effectiveInputDecorationTheme,
);
final InputDecoration textFieldDecoration = effectiveDecoration.prefixIcon == null
? effectiveDecoration
: effectiveDecoration.copyWith(
prefixIcon: SizedBox(
key: _leadingKey, // Used to query the width in refreshLeadingPadding.
child: effectiveDecoration.prefixIcon,
),
);

final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final bool isButton = !canRequestFocus();
Expand Down Expand Up @@ -1223,16 +1243,7 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
});
},
inputFormatters: widget.inputFormatters,
decoration: InputDecoration(
label: widget.label,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.leadingIcon != null
? SizedBox(key: _leadingKey, child: widget.leadingIcon)
: null,
suffixIcon: widget.showTrailingIcon ? trailingButton : null,
).applyDefaults(effectiveInputDecorationTheme),
decoration: textFieldDecoration,
restorationId: widget.restorationId,
),
),
Expand All @@ -1250,6 +1261,11 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
// and leadingButton.
//
// See _RenderDropdownMenuBody layout logic.
//
// TODO(bleroux): find a more accurate way to measure the text field minimum width.
// The text field width computation is not accurate as it is based only on label,
// prefixIcon and suffixIcon. Other InputDecoration parameters can have an
// impact on the total width.
children: <Widget>[
textField,
..._initialMenu!.map(
Expand All @@ -1263,8 +1279,14 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
child: DefaultTextStyle(style: effectiveTextStyle!, child: widget.label!),
),
),
trailingButton,
leadingButton,
effectiveDecoration.suffixIcon ?? const SizedBox.shrink(),
Padding(
// TODO(bleroux): find a more accurate way to get the correct width.
// This padding is used to mimic default input decorator padding.
// It won't be correct if non default values are used.
padding: const EdgeInsets.all(8.0),
child: effectiveDecoration.prefixIcon ?? const SizedBox.shrink(),
),
],
);

Expand Down Expand Up @@ -1337,6 +1359,47 @@ class _DropdownMenuState<T extends Object> extends State<DropdownMenu<T>> {
),
);
}

InputDecoration _buildDefaultDecoration(BuildContext context, MenuController controller) {
return InputDecoration(
label: widget.label,
hintText: widget.hintText,
helperText: widget.helperText,
errorText: widget.errorText,
prefixIcon: widget.leadingIcon,
suffixIcon: _buildDefaultSuffixIcon(context, controller),
);
}

Widget? _buildDefaultSuffixIcon(BuildContext context, MenuController controller) {
final bool isCollapsed = widget.inputDecorationTheme?.isCollapsed ?? false;
return widget.showTrailingIcon
? Padding(
padding: isCollapsed ? EdgeInsets.zero : const EdgeInsets.all(4.0),
child: ExcludeSemantics(
// When the text field is treated as a button (i.e., it can
// not be focused), the trailing button should become part of
// the text field button by excluding semantics. Otherwise,
// it will inappropriately announce whether this icon button
// is selected or not.
excluding: !canRequestFocus(),
child: IconButton(
focusNode: _trailingIconButtonFocusNode,
isSelected: controller.isOpen,
constraints: widget.inputDecorationTheme?.suffixIconConstraints,
padding: isCollapsed ? EdgeInsets.zero : null,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: !widget.enabled
? null
: () {
handlePressed(controller);
},
),
),
)
: null;
}
}

// `DropdownMenu` dispatches these private intents on arrow up/down keys.
Expand Down
Loading