32

I need to have a DropdownButton's list of options open/show programmatically when some other widget is tapped. I know that this may not be UI-best-practice and all, but I need this behavior:

As an example, in a structure like the one below, I may need to have taping Text("every") to open the neighboring DropdownButton's dropdown list, behaviors similar to clicking a <select>'s label in HTML.

Row(children: [
  Padding(
    padding: const EdgeInsets.only(right: 16),
    child: Text('every'),
  ),
  Expanded(
    child: DropdownButton<String>(
      value: _data['every'],
      onChanged: (String val) => setState(() => _data['every'] = val),
      items: _every_options.map<DropdownMenuItem<String>>(
        (String value) {
          return DropdownMenuItem<String>(
            value: value,
            child: Text(value),
          );
        },
      ).toList(),
      isExpanded: true,
    ),
  ),
]);

NOTE: I am in need though of the general solution to this problem, not just how to make that Text behave somewhat "like a HTML label" in the tree below. It may need to be triggered to open by maybe a further away button etc.

NeuronQ
  • 7,527
  • 9
  • 42
  • 60

2 Answers2

24

The other answer is the best way to do this, but as requested by the OP in comments, here are two very "hacky" ways to achieve this, yet without implementing custom widgets.

1. Access DropdownButton widget tree directly using GlobalKey

If we look at the source code of DropdownButton, we can notice that it uses GestureDetector to handle taps. However, it's not a direct descendant of DropdownButton, and we cannot depend on tree structure of other widgets, so the only reasonably stable way to find the detector is to do the search recursively.

One example is worth a thousand explanations:

class DemoDropdown extends StatefulWidget {  
  @override
  InputDropdownState createState() => DemoDropdownState();
}

class DemoDropdownState<T> extends State<DemoDropdown> {
  /// This is the global key, which will be used to traverse [DropdownButton]s widget tree
  GlobalKey _dropdownButtonKey;

  void openDropdown() {
    GestureDetector detector;
    void searchForGestureDetector(BuildContext element) {
      element.visitChildElements((element) {
        if (element.widget != null && element.widget is GestureDetector) {
          detector = element.widget;
          return false;

        } else {
          searchForGestureDetector(element);
        }

        return true;
      });
    }

    searchForGestureDetector(_dropdownButtonKey.currentContext);
    assert(detector != null);

    detector.onTap();
  }

  @override
  Widget build(BuildContext context) {
    final dropdown = DropdownButton<int>(
      key: _dropdownButtonKey,
      items: [
        DropdownMenuItem(value: 1, child: Text('1')),
        DropdownMenuItem(value: 2, child: Text('2')),
        DropdownMenuItem(value: 3, child: Text('3')),
      ],
      onChanged: (int value) {},
    );

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        Offstage(child: dropdown),
        FlatButton(onPressed: openDropdown, child: Text('CLICK ME')),
      ],
    );
  }
}

2. Use Actions.invoke

One of the recent features of Flutter is Actions (I'm not sure what it's meant for, I've only noticed it today after flutter upgrade), and DropdownButton uses it for reacting to different... well, actions.

So a little tiny bit less hacky way to trigger the button would be to find the context of Actions widget and invoke the necessary action.

There are two advantages of this approach: firstly, Actions widget is a bit higher in the tree, so traversing that tree wouldn't be as long as with GestureDetector, and secondly, Actions seems to be a more generic mechanism than gesture detection, so it's less likely to disappear from DropdownButton in the future.

// The rest of the code is the same
void openDropdown() {
  _dropdownButtonKey.currentContext?.visitChildElements((element) {
    if (element.widget is Semantics) {
      element.visitChildElements((element) {
        if (element.widget is Actions) {
          element.visitChildElements((element) {
            Actions.invoke(element, ActivateIntent());
          });
        }
      });
    }
  });
}
TijnvdEijnde
  • 117
  • 1
  • 2
  • 13
mrnateriver
  • 1,935
  • 1
  • 19
  • 19
  • 1
    The first solution works well. I could not make the second solution to work because the Intent doesn't receive an argument anymore. – Pedro Romão Feb 11 '21 at 17:51
  • 1
    Tried first one, works great! This should be deployed as a package. – Abbas Oct 12 '21 at 17:17
  • The code in the first method didn't work for me as is and had to be updated like so: https://gist.github.com/spauldhaliwal/942eec24895a7ed79e342aa784a62fa6 – Paul Dhaliwal Feb 11 '22 at 15:55
  • 2
    Approach #2 worked for me with the modified call `Actions.invoke(element, const ActivateIntent());` – Alan M Feb 16 '22 at 18:42
12

It's one (of many) designed API limitations...

The easiest approach to accomplish what you want, without modifying the SDK, copy dropdown.dart, and create your own version of it, let's say custom_dropdown.dart, and paste the code there ...

in line 546, rename the class to CustomDropdownButton, and in line 660 and 663 rename _DropdownButtonState to CustomDropdownButtonState, ( we need the state class to be exposed outside the file ).

Now you can do whatever you want with it, although you were interested in the _handleTap(), to open the overlay menu options.

Instead of making _handleTap() public, and refactor the code, add another method like:

(line 726)
void callTap() => _handleTap();

Now, change your code to use your DropdownButton instead of the Flutter's DropdownButton, the key is to "set the key" (Global one) :P

// some stateful widget implementation.

  Map<String, String> _data;
  List<String> _every_options;
  // we need the globalKey to access the State.
  final GlobalKey dropdownKey = GlobalKey();

  @override
  void initState() {
    _every_options = List.generate(10, (i) => "item $i");
    _data = {'every': _every_options.first};
    simulateClick();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Row(children: [
        Padding(
          padding: const EdgeInsets.only(right: 16),
          child: Text('every'),
        ),
        Expanded(
          child: CustomDropdownButton<String>(
            key: dropdownKey,
            value: _data['every'],
            onChanged: (String val) => setState(() => _data['every'] = val),
            items: _every_options
                .map((str) => DropdownMenuItem(
                      value: str,
                      child: Text(str),
                    ))
                .toList(),
            isExpanded: true,
          ),
        ),
      ]),
    );
  }

  void simulateClick() {
    Timer(Duration(seconds: 2), () {
      // here's the "magic" to retrieve the state... not very elegant, but works.
      CustomDropdownButtonState state = dropdownKey.currentState;
      state.callTap();
    });
  }
roipeker
  • 1,183
  • 9
  • 9
  • Thanks a lot for the detailed answer! Now, I'm weary of having to maintained this clone of DropdownButton going this way... do you know if maybe there isn't a "hackier" way to achieve this, maybe triggering/playing a tap event on a widget from another widget? I'm a beginner at Flutter, but I know that other UI frameworks always have some hacky workaround to trigger a fake tap/click/etc. – NeuronQ Aug 17 '19 at 08:03
  • @NeuronQ as far as im aware, is only doable through the flutter test framework, so is a no go. Even if flutter is kinda new, it is extremely stable, even from very early releases, and API didn’t change as far as I am aware. I doubt u will have to “maintain” this modified cloned version... the “hackier” way would be to not copy the widget to ur own class, and modify the sdk code... is as simple as ctr+click on the Widget in your IDE and add that callTap() function and rename the State without _... yet that will be overriden when u do flutter upgrade. – roipeker Aug 17 '19 at 08:44
  • @roipeker thank you, this solution helped me a great deal. One additional note, you do have to cut out a lot of stuff from the customized dart file unless you want to create custom versions of every class in there as dart will yell at you for having multiple sources for the same class. – WolfyD Aug 23 '19 at 07:45
  • This is more of a quick fix. But that means you'd have to manage this widget yourself. – Chima Precious Jun 25 '20 at 19:33