flutterdartuser-interfacedropdownflutter-build

Force the rebuild of an open dropdown in Flutter


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.

only showing to originally available data, even after build

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.


Solution

  • enter image description here

    
    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,
              ),
            ),
          ),
        );
      }
    }