0

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.

Andre Clements
  • 634
  • 1
  • 7
  • 15
  • 1
    I think the problem here is something else. The popup is a different page route and it doesn't listen to changes in the items. Seems like bug in the flutter framework. – Rahul Apr 21 '23 at 05:19

3 Answers3

1

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,
          ),
        ),
      ),
    );
  }
}
Kherel
  • 14,882
  • 5
  • 50
  • 80
  • Great, thank you, I can't see from the demo if the additional items appear without closing and reopening the dropdown though? – Andre Clements Apr 24 '23 at 07:11
  • It should work fine. You can try it, I put the code. – Kherel Apr 24 '23 at 07:17
  • Nope, it doesn't work. – Andre Clements Apr 24 '23 at 08:41
  • 1
    could you explain why you do you need to redraw the closed dropdown? – Kherel Apr 24 '23 at 08:51
  • It is to select the recipient for a message, recent recipients will probably already be available, but additional contacts are being fetched from a remote site via REST. Perhaps I have to build an entire dropdown using something like Overlay but that seems a bit, uhm primitive. I've also tried using just a DropdownButton and a global key to try and force a close and reopen of the widget but no luck. – Andre Clements Apr 24 '23 at 08:58
  • 1
    right, then you need custom dropdown, I've update my answer – Kherel Apr 24 '23 at 10:20
  • 1
    Thank you for the effort @Kherel, I can certainly work with that. – Andre Clements Apr 25 '23 at 06:52
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/253304/discussion-between-andre-clements-and-kherel). – Andre Clements Apr 25 '23 at 06:52
0

Try making all the changes that you wish to happen inside setState({<here>});.

I have a feeling that you call setState too early- when not all the properties were updated - this way your code is rebuild with updated properties always.

empty setState issues

Unfortunatelly your code is lengthy and incomplete so there is no way to debug it.

Jan-Stepien
  • 349
  • 2
  • 9
-1

You can try creating an StatefullWidget and putting your dropdown there then by calling its setState you should be able to update the dropdown menu items

Mohammad Hosein
  • 457
  • 1
  • 3
  • 11