When a `DropdownButtonFormField' widget's build method is executed, if the user has already clicked on it and the options are diplayed in the UI, it seems those options don't get updated by the build method. I've tried various aproaches and asked the AIs to no avail; :-(
Currently, the widget is
//...
import 'package:get_it/get_it.dart';
//...
class DynamicDropdown extends StatefulWidget {
final void Function(ClientLItem) onSelected;
const DynamicDropdown(
{super.key,
/*required Key key,*/ required this.onSelected}) /*: super(key: key)*/;
@override
_DynamicDropdownState createState() => _DynamicDropdownState();
}
class _DynamicDropdownState extends State<DynamicDropdown> {
String name = '_DynamicDropdownState';
ClientLItem? _selectedClient;
String dropdownValue = "0";
bool shouldUpdate = false;
ClientListicle _clientListicle = GetIt.I.get<ClientListicle>();
List<DropdownMenuItem<String>> menuItems = [
const DropdownMenuItem(value: "0", child: Text('Select')),
];
final _dropdownKey = GlobalKey<FormFieldState>();
List<DropdownMenuItem<String>>? get dropdownItems {
developer.log(
'get dropdownItems() clients have ${_clientListicle.items.length} clients',
name: name);
List<DropdownMenuItem<String>> newItems = [
const DropdownMenuItem(value: "0", child: Text('Select')),
];
for (var element in _clientListicle.items) {
DropdownMenuItem<String> potentialItem = DropdownMenuItem(
value: element.id.toString(), child: Text(element.title));
newItems.add(potentialItem);
developer.log('menu items count ${newItems.length}', name: name);
}
developer.log('new menu items count ${newItems.length}', name: name);
if (newItems.length <= 1) {
return null;
} else {
//if length of newitems differs from menu items schedule a forced rebuild
if (newItems.length != menuItems.length) {
menuItems = newItems;
shouldUpdate = true;
_onClientListicleUpdated();
_dropdownKey.currentState!.build(context);
}
return menuItems;
}
}
@override
void initState() {
developer.log('initState', name: name);
super.initState();
// Listen for changes in the ClientListicle instance
_clientListicle.addListener(_onClientListicleUpdated);
// if _clientListicle.items.isEmpty load some clients
WidgetsBinding.instance.addPostFrameCallback((_) async {
developer.log('addPostFrameCallback', name: name);
if (_clientListicle.items.isEmpty) {
fetchClients();
}
if (shouldUpdate) {
shouldUpdate = false;
setState(() {
// update state here
});
_dropdownKey.currentState!.reset();
}
});
}
@override
void dispose() {
// Remove the listener when the widget is disposed
_clientListicle.removeListener(_onClientListicleUpdated);
super.dispose();
}
void _onClientListicleUpdated() {
developer.log('_onClientListicleUpdated');
// Call setState to rebuild the widget when the ClientListicle instance is updated
setState(() {
dropdownItems;
});
_dropdownKey.currentState!.reset();
}
@override
State<StatefulWidget> createState() {
developer.log('createState()');
return _DynamicDropdownState();
}
@override
Widget build(BuildContext context) {
developer.log(
'Build ClientListicle has ${_clientListicle.items.length} items',
name: name);
developer.log('dropdownItems has ${dropdownItems?.length} items',
name: name);
if (shouldUpdate) {
developer.log('shouldUpdate is true', name: name);
shouldUpdate = false;
// Schedule a rebuild
setState(() {});
}
return DropdownButtonFormField<String>(
key: _dropdownKey,
value: dropdownValue,
icon: const Icon(Icons.keyboard_arrow_down),
items: dropdownItems,
validator: (value) => value == "0" ? 'Please select a client' : null,
onChanged: (String? newValue) {
if (newValue != null && newValue != "0") {
developer.log('selected newValue $newValue', name: name);
dropdownValue = newValue;
_selectedClient =
_clientListicle.getById(int.parse(newValue!)) as ClientLItem;
setState(() {});
widget.onSelected(_selectedClient!);
} else {
_selectedClient = null;
dropdownValue = "0";
}
_dropdownKey.currentState!.reset();
},
);
}
Future<void> fetchClients() async {
developer.log('fetchClients', name: name);
await _clientListicle.fetch(numberToFetch: 5);
}
}
Based on the log output I can see that e.g. [_DynamicDropdownState] Build ClientListicle has 3 items
, but still see only the single item that was available when I clicked on the dropdown before the data had arrived.
If I click outside the dropdown and reopen it the correct items appear, so setState inside the stateful widget appears not to force a rebuild of the options list in the UI.
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: DynamicDropdown(
onSelect: (selected) {
print(selected);
},
onDeselect: () {
print('deselected');
},
),
),
);
}
}
//...
class DynamicDropdown extends StatefulWidget {
final void Function(Client) onSelect;
final VoidCallback onDeselect;
const DynamicDropdown({
super.key,
required this.onSelect,
required this.onDeselect,
});
@override
State<DynamicDropdown> createState() => _DynamicDropdownState();
}
class _DynamicDropdownState extends State<DynamicDropdown> {
final clientService = FakeClientListService();
String selected = "0";
@override
void initState() {
imitatateFetchingClients();
super.initState();
}
void imitatateFetchingClients() async {
await Future.delayed(const Duration(seconds: 1));
await clientService.fetch(numberToFetch: 3);
}
void _handleClick() async {
final NavigatorState navigator = Navigator.of(context);
final RenderBox itemBox = context.findRenderObject()! as RenderBox;
final Rect itemRect = itemBox.localToGlobal(Offset.zero,
ancestor: navigator.context.findRenderObject()) &
itemBox.size;
var selected = await navigator.push(
CustomPopupRoute(
builder: (context) => Menu(
buttonRect: itemRect,
),
),
);
if (selected != null) {
if (selected == "0") {
widget.onDeselect();
} else {
final client = clientService.clients
.firstWhere((element) => element.id == selected);
widget.onSelect(client);
}
setState(() {
this.selected = selected;
});
}
}
@override
Widget build(BuildContext context) {
late String selectedText;
if (selected == "0") {
selectedText = "Select";
} else {
selectedText = clientService.clients
.firstWhere((element) => element.id == selected)
.name;
}
return GestureDetector(
onTap: _handleClick,
child: Container(
width: 300,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.black),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
Expanded(child: Text(selectedText)),
const Icon(Icons.keyboard_arrow_down),
],
),
),
);
}
}
class FakeClientListService {
static FakeClientListService? _instance;
FakeClientListService._();
factory FakeClientListService() => _instance ??= FakeClientListService._();
final List<Client> _clients = <Client>[];
List<Client> get clients => _clients;
final controller = StreamController<List<Client>>.broadcast();
Stream<List<Client>> get stream => controller.stream;
Future<void> fetch({
required int numberToFetch,
}) async {
await Future.delayed(const Duration(seconds: 2));
final newClients = names.take(5).toList();
_clients.addAll(newClients);
controller.add(newClients);
print(_clients);
}
}
class Client {
final String name;
final String id;
const Client({required this.name, required this.id});
}
const names = [
Client(
name: 'Ava',
id: '1',
),
Client(
name: 'Ethan',
id: '2',
),
Client(
name: 'Isabella',
id: '3',
),
Client(
name: 'Liam',
id: '4',
),
Client(
name: 'Sophia',
id: '5',
),
Client(
name: 'Noah',
id: '6',
),
Client(
name: 'Mia',
id: '7',
),
Client(
name: 'Oliver',
id: '8',
),
Client(
name: 'Emma',
id: '9',
),
Client(
name: 'William',
id: '10',
),
];
class CustomPopupRoute extends PopupRoute {
CustomPopupRoute({
required this.builder,
});
final WidgetBuilder builder;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => null;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
print(constraints);
return MediaQuery.removePadding(
context: context,
removeTop: false,
removeBottom: false,
removeLeft: false,
removeRight: false,
child: Builder(
builder: builder,
),
);
},
);
}
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
}
class Menu extends StatefulWidget {
const Menu({
super.key,
required this.buttonRect,
});
final Rect buttonRect;
@override
State<Menu> createState() => _MenuState();
}
class _MenuState extends State<Menu> {
final clientService = FakeClientListService();
late final StreamSubscription<List<Client>> subscription;
@override
void dispose() {
subscription.cancel();
super.dispose();
}
@override
void initState() {
super.initState();
subscription = clientService.stream.listen((event) {
setState(() {});
});
}
@override
Widget build(BuildContext context) {
final top = widget.buttonRect.top + widget.buttonRect.height + 10;
return Padding(
padding: EdgeInsets.only(
top: top,
left: widget.buttonRect.left,
right: MediaQuery.of(context).size.width - widget.buttonRect.right,
bottom: MediaQuery.of(context).size.height -
top -
clientService.clients.length * 40 -
40 -
2,
),
child: Material(
color: Colors.transparent,
elevation: 0,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.black),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
MenuItem(
onTap: () {
Navigator.of(context).pop('0');
},
text: 'Select',
),
for (final client in clientService.clients)
MenuItem(
onTap: () {
Navigator.of(context).pop(client.id);
},
text: client.name,
),
],
),
),
),
),
),
);
}
}
class MenuItem extends StatelessWidget {
const MenuItem({
super.key,
required this.text,
required this.onTap,
});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: InkWell(
splashColor: Colors.green,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
height: 40,
alignment: Alignment.centerLeft,
child: Text(
text,
),
),
),
);
}
}