Visitar URL original
[WIP][material_ui] add DropdownButton.builder constructor for lazy item loading by Mairramer · Pull Request #13124 · flutter/packages · GitHub
Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
refactor: remove value parameter from lazy DropdownButton constructor…
… in favor of selectedItemIndex
  • Loading branch information
Mairramer committed Oct 4, 2026
commit 536f5bf97a8806e95ab4765a16b52241bd225869
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ class DropdownButtonBuilderExample extends StatefulWidget {
}

class _DropdownButtonBuilderExampleState extends State<DropdownButtonBuilderExample> {
int? dropdownValue = 0;
int? dropdownValue = 100;

@override
Widget build(BuildContext context) {
return DropdownButton<int>.builder(
value: dropdownValue,
selectedItemIndex: 100,
selectedItemIndex: dropdownValue,
itemCount: 10000,
itemBuilder: (BuildContext context, int index) {
return DropdownMenuItem<int>(value: index, child: Text('Item $index'));
Expand Down
50 changes: 40 additions & 10 deletions packages/material_ui/lib/src/dropdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1103,11 +1103,31 @@ class DropdownButton<T> extends StatefulWidget {
_inputDecoration = null,
_isEmpty = false;

/// Creates a dropdown button that builds its items lazily.
/// Creates a dropdown button that builds its items lazily on demand.
///
/// This constructor is useful when the dropdown menu contains a large number
/// of items, as it only creates the widgets for the items that are currently
/// visible in the menu, significantly improving performance.
/// This constructor is appropriate for dropdown menus with a large (or infinite)
/// number of items because the [itemBuilder] is called only for those items
/// that are actually visible in the dropdown menu.
///
/// Providing a non-null [itemCount] improves the ability of the internal
/// [ListView] to estimate the maximum scroll extent and accurately compute
/// the scrollbar's thumb size.
///
/// The [itemBuilder] callback will be called only with indices greater than
/// or equal to zero and less than [itemCount]. It must return a valid
/// [DropdownMenuItem] of type [T].
///
/// Unlike the default [DropdownButton] constructor, which uses a `value`
/// parameter to find the selected item via a linear search (O(N)), this
/// constructor requires a [selectedItemIndex].
///
/// Passing the index directly guarantees O(1) performance, as the dropdown
/// can instantly scroll to the correct position and extract the selected value
/// by calling the [itemBuilder] exactly once for that index.
///
/// If the underlying data changes (e.g. the list is filtered, sorted, or
/// items are added/removed), the developer is responsible for updating the
/// [selectedItemIndex] to reflect the item's new position.
///
/// <callout-box>
///
Expand All @@ -1128,7 +1148,6 @@ class DropdownButton<T> extends StatefulWidget {
this.selectedItemIndex,
this.customSelectedItemBuilder,
this.selectedItemBuilder,
this.value,
this.hint,
this.disabledHint,
required this.onChanged,
Expand Down Expand Up @@ -1157,6 +1176,7 @@ class DropdownButton<T> extends StatefulWidget {
this.mouseCursor,
this.dropdownMenuItemMouseCursor,
}) : items = null,
value = null,
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
isVerticallyExpanded = true,
_inputDecoration = null,
Expand Down Expand Up @@ -1635,6 +1655,14 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
}
}

T? get _effectiveValue {
if (widget.itemBuilder != null && _selectedIndex != null) {
final DropdownMenuItem<T> item = widget.itemBuilder!(context, _selectedIndex!);
return item.value;
}
return widget.value;
}

TextStyle? get _textStyle => widget.style ?? Theme.of(context).textTheme.titleMedium;

void _handleTap() {
Expand Down Expand Up @@ -1814,8 +1842,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
// If value is null (then _selectedIndex is null) then we
// display the hint or nothing at all.
final Widget innerItemsWidget;
final bool showHint =
(!_enabled) || (widget.itemBuilder != null ? widget.value == null : _selectedIndex == null);
final bool showHint = (!_enabled) || _selectedIndex == null;

if (showHint && hintIndex != null) {
innerItemsWidget = IndexedStack(
Expand All @@ -1830,13 +1857,16 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
}).toList(),
);
} else if (widget.itemBuilder != null) {
if (!_enabled) {
if (!_enabled || _selectedIndex == null) {
innerItemsWidget = const SizedBox.shrink();
} else {
if (widget.customSelectedItemBuilder != null) {
innerItemsWidget = widget.customSelectedItemBuilder!(context, widget.value);
innerItemsWidget = widget.customSelectedItemBuilder!(context, _effectiveValue);
} else {
innerItemsWidget = Text(widget.value?.toString() ?? '');
final DropdownMenuItem<T> item = widget.itemBuilder!(context, _selectedIndex!);
innerItemsWidget = widget.itemHeight != null
? SizedBox(height: widget.itemHeight, child: item)
: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[item]);
}
}
} else if (items.isEmpty) {
Expand Down
18 changes: 10 additions & 8 deletions packages/material_ui/test/dropdown_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5027,7 +5027,6 @@ void main() {
home: Scaffold(
body: Center(
child: DropdownButton<int>.builder(
value: 0,
selectedItemIndex: 0,
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
Expand All @@ -5046,7 +5045,10 @@ void main() {

// Initial state: menu is closed.
expect(find.text('Selected: 0'), findsOneWidget);
expect(buildCount, 0); // No items built yet because menu is closed
// We expect 1 widget build here because when DropdownButton renders its closed state,
// it calls itemBuilder for the selectedItemIndex (0) in order to get the item's value
// (which is then passed to customSelectedItemBuilder).
expect(buildCount, 1);

// Tap to open menu
await tester.tap(find.text('Selected: 0'));
Expand Down Expand Up @@ -5076,7 +5078,7 @@ void main() {
return Scaffold(
body: Center(
child: DropdownButton<int>.builder(
value: selectedValue,
selectedItemIndex: selectedValue,
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return DropdownMenuItem<int>(value: index, child: Text('Item $index'));
Expand Down Expand Up @@ -5116,7 +5118,7 @@ void main() {
home: Scaffold(
body: Center(
child: DropdownButton<int>.builder(
value: 0,
selectedItemIndex: 0,
itemCount: 0,
hint: const Text('Empty Hint'),
itemBuilder: (BuildContext context, int index) {
Expand All @@ -5133,15 +5135,15 @@ void main() {
expect(find.text('Empty Hint'), findsOneWidget);
});

testWidgets('falls back to value.toString() when customSelectedItemBuilder is null', (
testWidgets('falls back to item child when customSelectedItemBuilder is null', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: DropdownButton<String>.builder(
value: 'My String Value',
selectedItemIndex: 0,
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return DropdownMenuItem<String>(
Expand All @@ -5156,8 +5158,8 @@ void main() {
),
);

// Should automatically use the value stringified if no customSelectedItemBuilder is provided
expect(find.text('My String Value'), findsOneWidget);
// Should automatically use the item's child if no customSelectedItemBuilder is provided
expect(find.text('Item 0'), findsOneWidget);
});
});
}
Loading