How to implement a multi-category selector in Flutter

The goal of this blog post is to create a Widget that allows a user to select multiple categories from a list. We are going to use a list of Formula 1 drivers so that users can compare the results of those drivers. We already showed how to query multiple drivers from a GraphQL endpoint, but this was missing a selection of which drivers should be queried. In this blog post, we will show two approaches on how to do this. First, we will show you how to create a checkbox list where we can turn on and off each driver. After that, we will show you how to react to each click.

Checkbox list

Let’s start with a checkbox list. The Flutter material API contains a useful class that we are going to use, namely the CheckboxListTile class. We can wrap this class in a ListView to make a list of items with a checkbox. We will show you how to do this for a simple list of names :

class CategorySelector extends StatefulWidget {
  final List<String> categories;

  CategorySelector(this.categories);

  @override
  _CategorySelectorState createState() => _CategorySelectorState();
}

class _CategorySelectorState extends State<CategorySelector> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Flexible(
          child: ListView.builder(
              itemCount: widget.categories.length,
              itemBuilder: (BuildContext context, int index) {
                return CheckboxListTile(
                  value: false,
                  onChanged: (bool selected) {},
                  title: Text(widget.categories[index]),
                );
              }),
        )
      ],
    );
  }
}

Now that we have a list with checkboxes, but nothing happens on selection. We have to manage the state of the selected items. This is the reason we created a Stateful Widget. We are going to add an action to the onChange method and set the value to something different as false.

class _CategorySelectorState extends State<CategorySelector> {
  final _selectedCategories = List<String>();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Flexible(
          child: ListView.builder(
              itemCount: widget.categories.length,
              itemBuilder: (BuildContext context, int index) {
                return CheckboxListTile(
                  value: _selectedCategories.contains(widget.categories[index]),
                  onChanged: (bool selected) {
                    setState(() {
                      if(selected) {
                        _selectedCategories.add(widget.categories[index]);
                      } else {
                        _selectedCategories.remove(widget.categories[index]);
                      }
                    });
                  },
                  title: Text(widget.categories[index]),
                );
              }),
        ),
      ],
    );
  }
}

After we have selected the values we want, we still have to use provide them to another Widget. One way to do this is to add a button on the bottom. When the user presses the button, the user will be rerouted to a different Widget with the selected values.

class _CategorySelectorState extends State<CategorySelector> {
  final _selectedCategories = List<String>();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Flexible(
          child: ListView.builder(
              itemCount: widget.categories.length,
              itemBuilder: (BuildContext context, int index) {
                return CheckboxListTile(
                  value: _selectedCategories.contains(widget.categories[index]),
                  onChanged: (bool selected) {
                    setState(() {
                      if(selected) {
                        _selectedCategories.add(widget.categories[index]);
                      } else {
                        _selectedCategories.remove(widget.categories[index]);
                      }
                    });
                  },
                  title: Text(widget.categories[index]),
                );
              }),
        ),
        RaisedButton(
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (context) => SelectedCategories(
                        categories: _selectedCategories,
                      )),
            );
          },
          child: Text("Done"),
        )
      ],
    );
  }
}

class SelectedCategories extends StatelessWidget {
  final List<String> categories;

  const SelectedCategories({Key key, this.categories}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Selected categories")),
      body: Container(
        child: ListView.builder(
            itemCount: categories.length,
            itemBuilder: (BuildContext context, int index) {
              return Padding(
                padding: const EdgeInsets.all(8.0),
                child: Text(categories[index]),
              );
            }),
      ),
    );
  }
}

If this is exactly what you need, you can play around with the code so far on this Dartpad.

Interactive Widgets

Another option is to do something on each click. We will provide a callback to the parent widget that will handle the click. The callback will be called at the selected function. This way we can call the parent widget to update the list of selected categories. The parent Widget can do something with those values.

class _CategorySelectorState extends State<CategorySelector> {
  final selectedCategories = List<String>();

  handleChange(String name, bool selected) {
    setState(() {
      if (selected) {
        selectedCategories.add(name);
      } else {
        selectedCategories.remove(name);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CategoryFilter(
          categories: widget.categories,
          selected: selectedCategories,
          callback: handleChange,
        ),
        Container(
          color: Colors.green,
          height: 2,
        ),
        SelectedCategories(
          categories: selectedCategories,
        )
      ],
    );
  }
}

The CategoryFilter will display the list of categories. The onChange method will call the handleChange in the parent method to update the state. This means that the CategoryFilter can be a StatelessWidget and the state remains at the parent Widget. The parent Widget will then also update the SelectedCategories on each change.

class CategoryFilter extends StatelessWidget {
  final List<String> categories;
  final List<String> selected;
  final Function(String, bool) callback;
  const CategoryFilter({Key key, this.categories, this.selected, this.callback})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Flexible(
      child: ListView.builder(
          itemCount: categories.length,
          itemBuilder: (BuildContext context, int index) {
            return CheckboxListTile(
              value: selected.contains(categories[index]),
              onChanged: (bool selected) {
                callback(categories[index], selected);
              },
              title: Text(categories[index]),
            );
          }),
    );
  }
}

That is it! If you want to play around with the code, you can do so on the Dartpad or find the full code on Github. Thank you for reading, and if you have any questions feel free to leave a comment!

Leave a Reply