Search code examples
fluttercheckboxdropdownpopupmenu

How to select all checkboxes in a Flutter PopupMenu when selecting one checkbox?


A similar question has been asked before for Flutter see question. However no valid answer was given, so it may be worth reopening.

Here is a complete code example.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

const cities = <String>{
  'Atlanta',
  'Baltimore',
  'Boston',
  'Chicago',
  'Denver',
  'Houston',
  'Los Angeles',
  'Philadelphia',
  'San Francisco',
  'Washington, DC',
};

enum SelectionState {
  all('(All)'),
  none('(None)'),
  some('(Some)');

  const SelectionState(this._value);
  final String _value;

  @override
  String toString() => _value;
}

class SelectionModel {
  SelectionModel({required this.selection, required this.choices});

  late final Set<String> selection;
  final Set<String> choices;

  SelectionState get selectionState {
    if (selection.isEmpty) return SelectionState.none;
    if (choices.difference(selection).isNotEmpty) {
      return SelectionState.some;
    } else {
      return SelectionState.all;
    }
  }

  SelectionModel add(String value) {
    if (value == '(All)') {
      return SelectionModel(selection: {...choices}, choices: choices);
    } else {
      return SelectionModel(selection: selection..add(value), choices: choices);
    }
  }

  SelectionModel remove(String value) {
    if (value == '(All)') {
      return SelectionModel(selection: <String>{}, choices: choices);
    } else {
      selection.remove(value);
      return SelectionModel(selection: selection, choices: choices);
    }
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dropdown with Select (All)',
      theme: ThemeData(
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  SelectionModel model =
      SelectionModel(selection: {...cities}, choices: {...cities});

  List<PopupMenuItem<String>> getCheckboxList() {
    var out = <PopupMenuItem<String>>[];
    out.add(PopupMenuItem<String>(
        padding: EdgeInsets.zero,
        value: '(All)',
        child: StatefulBuilder(builder: (context, setState) {
          return CheckboxListTile(
            value: model.selectionState == SelectionState.all,
            controlAffinity: ListTileControlAffinity.leading,
            title: const Text('(All)'),
            onChanged: (bool? checked) {
              setState(() {
                if (checked!) {
                  model = model.add('(All)');
                } else {
                  model = model.remove('(All)');
                }
              });
            },
          );
        })));

    for (final value in model.choices) {
      out.add(PopupMenuItem<String>(
          padding: EdgeInsets.zero,
          value: value,
          child: StatefulBuilder(builder: (context, setState) {
            return CheckboxListTile(
              value: model.selection.contains(value),
              controlAffinity: ListTileControlAffinity.leading,
              title: Text(value),
              onChanged: (bool? checked) {
                setState(() {
                  if (checked!) {
                    model = model.add(value);
                  } else {
                    model = model.remove(value);
                  }
                });
              },
            );
          })));
    }
    return out;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.all(20.0),
              child: Container(
                width: 250,
                color: Colors.orangeAccent,
                child: PopupMenuButton<String>(
                  constraints: const BoxConstraints(maxHeight: 400),
                  position: PopupMenuPosition.under,
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Row(
                      children: [
                        Text(model.selectionState.toString()),
                        const Spacer(),
                        const Icon(Icons.keyboard_arrow_down_outlined),
                      ],
                    ),
                  ),
                  itemBuilder: (context) {
                    return getCheckboxList();
                  },
                  onCanceled: () {
                    setState(() {
                      model = SelectionModel(
                          selection: model.selection, choices: model.choices);
                    });
                  },
                ),
              ),
            ),
            const Spacer(),
            Text('Selected cities: ${model.selection.join(', ')}'),
          ],
        ),
      ),
    );
  }
}

See screeenshot. base1

If I click individual cities, everything is fine. If I click the (All) checkbox, I would like all the checkboxes to turn false (which does not happen unless I close the menu.)

How can I do this? If I just have a list of CheckboxListTiles in the main app, the logic works fine, and the checkboxes update as I want. However, once they are part of the menu, it doesn't work properly anymore.

Thank you for any help with this! Tony


Solution

  • You need to bridge a connection between all and others items. Using separate StateFulBuilder is just updating different section of UI. I am extending this approach with ValueNotifier.

      SelectionModel selectAll() {
        return SelectionModel(selection: {...choices}, choices: choices);
      }
    
      SelectionModel selectNone() {
        return SelectionModel(selection: <String>{}, choices: choices);
      }
    

    And the ValueNotifier.

      ValueNotifier<SelectionModel> model = ValueNotifier(
          SelectionModel(selection: {...cities}, choices: {...cities}));
    
    ///there are N things can be improved, 
    void main() {
      runApp(const MyApp());
    }
    
    const cities = <String>{
      'Atlanta',
      'Baltimore',
      'Boston',
      'Chicago',
      'Denver',
      'Houston',
      'Los Angeles',
      'Philadelphia',
      'San Francisco',
      'Washington, DC',
    };
    
    enum SelectionState {
      all('(All)'),
      none('(None)'),
      some('(Some)');
    
      const SelectionState(this._value);
      final String _value;
    
      @override
      String toString() => _value;
    }
    
    class SelectionModel {
      SelectionModel({required this.selection, required this.choices});
    
      late final Set<String> selection;
      final Set<String> choices;
    
      SelectionState get selectionState {
        if (selection.isEmpty) return SelectionState.none;
        if (choices.difference(selection).isNotEmpty) {
          return SelectionState.some;
        } else {
          return SelectionState.all;
        }
      }
    
      SelectionModel add(String value) {
        if (value == '(All)') {
          return SelectionModel(selection: {...choices}, choices: choices);
        } else {
          return SelectionModel(selection: selection..add(value), choices: choices);
        }
      }
    
      SelectionModel remove(String value) {
        if (value == '(All)') {
          return SelectionModel(selection: <String>{}, choices: choices);
        } else {
          selection.remove(value);
          return SelectionModel(selection: selection, choices: choices);
        }
      }
    
      SelectionModel selectAll() {
        return SelectionModel(selection: {...choices}, choices: choices);
      }
    
      SelectionModel selectNone() {
        return SelectionModel(selection: <String>{}, choices: choices);
      }
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Dropdown with Select (All)',
          theme: ThemeData(
            useMaterial3: true,
          ),
          home: const MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key});
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      ValueNotifier<SelectionModel> model = ValueNotifier(
          SelectionModel(selection: {...cities}, choices: {...cities}));
    
      List<PopupMenuItem<String>> getCheckboxList(setStateSB) {
        var out = <PopupMenuItem<String>>[];
        out.add(PopupMenuItem<String>(
            padding: EdgeInsets.zero,
            value: '(All)',
            child: ValueListenableBuilder(
              valueListenable: model,
              builder: (context, value, child) => CheckboxListTile(
                  value: model.value.selectionState == SelectionState.all,
                  controlAffinity: ListTileControlAffinity.leading,
                  title: const Text('(All)'),
                  onChanged: (bool? checked) {
                    if (checked == true) {
                      model.value = model.value.selectAll();
                    } else {
                      model.value = model.value.selectNone();
                    }
                    setStateSB(() {});
                  }),
            )));
    
        for (final value in model.value.choices) {
          out.add(PopupMenuItem<String>(
              padding: EdgeInsets.zero,
              value: value,
              child: ValueListenableBuilder(
                valueListenable: model,
                builder: (context, _, child) => CheckboxListTile(
                  // just using your approach
                  value: model.value.selection.contains(value),
                  controlAffinity: ListTileControlAffinity.leading,
                  title: Text(value),
                  onChanged: (bool? checked) {
                    setStateSB(() {
                      if (checked!) {
                        model.value = model.value.add(value);
                      } else {
                        model.value = model.value.remove(value);
                      }
                    });
                  },
                ),
              )));
        }
        return out;
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.all(20.0),
                  child: Container(
                    width: 250,
                    color: Colors.orangeAccent,
                    child: StatefulBuilder(
                      builder: (context, setStateSB) => PopupMenuButton<String>(
                        constraints: const BoxConstraints(maxHeight: 400),
                        position: PopupMenuPosition.under,
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Row(
                            children: [
                              Text(model.value.selectionState.toString()),
                              const Spacer(),
                              const Icon(Icons.keyboard_arrow_down_outlined),
                            ],
                          ),
                        ),
                        itemBuilder: (context) {
                          return getCheckboxList(setStateSB);
                        },
                        onCanceled: () {
                          setState(() {
                            model.value = SelectionModel(
                                selection: model.value.selection,
                                choices: model.value.choices);
                          });
                        },
                      ),
                    ),
                  ),
                ),
                const Spacer(),
                Text('Selected cities: ${model.value.selection.join(', ')}'),
              ],
            ),
          ),
        );
      }
    }