Custom Choice Chips for FlutterFlow

General Conversations

FlutterFlow’s default choice chip widget does not support mapping both labels and values together. So I created this custom widget to handle that limitation — useful when you need to track selected items by an ID while showing user-friendly labels.

You can also set a maximum number of selectable chips, which is useful for features like selecting interests, tags, or filters with a limit.



💡 What It Does

  • Displays a group of choice chips (using FilterChip)

  • Maps each chip label (String) to a corresponding value (int)

  • Supports multiple selection, with a limit if needed

  • Calls a callback when the selection changes

  • Allows full styling (colors, padding, font size, etc.)

    // Automatic FlutterFlow imports
    import '/backend/schema/structs/index.dart';
    import '/backend/schema/enums/enums.dart';
    import '/backend/supabase/supabase.dart';
    import '/actions/actions.dart' as action_blocks;
    import '/flutter_flow/flutter_flow_theme.dart';
    import '/flutter_flow/flutter_flow_util.dart';
    import '/custom_code/widgets/index.dart'; // Imports other custom widgets
    import '/custom_code/actions/index.dart'; // Imports custom actions
    import '/flutter_flow/custom_functions.dart'; // Imports custom functions
    import 'package:flutter/material.dart';
    // Begin custom widget code
    // DO NOT REMOVE OR MODIFY THE CODE ABOVE!
    
    class CustomChoiceChips extends StatefulWidget {
      const CustomChoiceChips({
        super.key,
        this.width,
        this.height,
        required this.labels,
        required this.values,
        this.maxSelections,
        this.initialSelectedValues,
        required this.onSelectionChanged,
        this.selectedColor,
        this.unselectedColor,
        this.selectedTextColor,
        this.unselectedTextColor,
        this.spacing,
        this.runSpacing,
        this.fontSize,
        this.borderRadius,
        this.padding,
      });
    
      final double? width;
      final double? height;
      final List<String> labels;
      final List<int> values;
      final int? maxSelections;
      final List<int>? initialSelectedValues;
      final Future Function(List<int>? selected) onSelectionChanged;
      final Color? selectedColor;
      final Color? unselectedColor;
      final Color? selectedTextColor;
      final Color? unselectedTextColor;
      final double? spacing;
      final double? runSpacing;
      final double? fontSize;
      final double? borderRadius;
      final double? padding;
    
      // Get the current state to access the selectedValues
      _CustomChoiceChipsState? get currentState => _key.currentState;
    
      // Global key to access state
      static final GlobalKey<_CustomChoiceChipsState> _key =
          GlobalKey<_CustomChoiceChipsState>();
    
      @override
      State<CustomChoiceChips> createState() => _CustomChoiceChipsState();
    
      // Static method to create the widget with the global key
      static CustomChoiceChips create({
        double? width,
        double? height,
        required List<String> labels,
        required List<int> values,
        int? maxSelections,
        List<int>? initialSelectedValues,
        required Future Function(List<int>? selected) onSelectionChanged,
        Color? selectedColor,
        Color? unselectedColor,
        Color? selectedTextColor,
        Color? unselectedTextColor,
        double? spacing,
        double? runSpacing,
        double? fontSize,
        double? borderRadius,
        double? padding,
      }) {
        return CustomChoiceChips(
          key: _key,
          width: width,
          height: height,
          labels: labels,
          values: values,
          maxSelections: maxSelections,
          initialSelectedValues: initialSelectedValues,
          onSelectionChanged: onSelectionChanged,
          selectedColor: selectedColor,
          unselectedColor: unselectedColor,
          selectedTextColor: selectedTextColor,
          unselectedTextColor: unselectedTextColor,
          spacing: spacing,
          runSpacing: runSpacing,
          fontSize: fontSize,
          borderRadius: borderRadius,
          padding: padding,
        );
      }
    }
    
    class _CustomChoiceChipsState extends State<CustomChoiceChips> {
      late List<int> _selectedValues;
    
      // Getter for selected values as List<int>
      List<int> get selectedValues => List<int>.unmodifiable(_selectedValues);
    
      @override
      void initState() {
        super.initState();
        _selectedValues = widget.initialSelectedValues != null
            ? List<int>.from(widget.initialSelectedValues!)
            : <int>[];
    
        // Ensure not exceeding maxSelections if specified
        if (widget.maxSelections != null &&
            _selectedValues.length > widget.maxSelections!) {
          _selectedValues = _selectedValues.sublist(0, widget.maxSelections);
          WidgetsBinding.instance.addPostFrameCallback((_) {
            widget.onSelectionChanged(
              _selectedValues.isEmpty ? null : _selectedValues,
            );
          });
        }
      }
    
      Future<void> _toggleSelection(int value) async {
        // Check if we need to show the max selection warning
        if (!_selectedValues.contains(value) &&
            widget.maxSelections != null &&
            _selectedValues.length >= widget.maxSelections!) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('Maximum ${widget.maxSelections} interests allowed'),
              duration: const Duration(seconds: 1),
            ),
          );
          return;
        }
    
        // Create a local copy of the selected values list
        List<int> updatedValues = List<int>.from(_selectedValues);
    
        // Update the local copy
        if (updatedValues.contains(value)) {
          updatedValues.remove(value);
        } else {
          updatedValues.add(value);
        }
    
        // Use setState to apply the changes atomically
        setState(() {
          _selectedValues = updatedValues;
        });
    
        // Call the callback after the state is updated
        await widget.onSelectionChanged(
            _selectedValues.isEmpty ? null : List<int>.from(_selectedValues));
      }
    
      @override
      Widget build(BuildContext context) {
        // If height is 0, use a flexible container instead of a fixed-height SizedBox
        if (widget.height == 0) {
          return Container(
            width: widget.width,
            child: _buildContent(),
          );
        } else {
          return SizedBox(
            width: widget.width,
            height: widget.height,
            child: _buildContent(),
          );
        }
      }
    
      Widget _buildContent() {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min, // Use minimum space needed
          children: [
            Wrap(
              spacing: widget.spacing ?? 8.0,
              runSpacing: widget.runSpacing ?? 8.0,
              children: List.generate(
                widget.labels.length,
                (index) {
                  final isSelected = _selectedValues.contains(widget.values[index]);
                  return Padding(
                    padding: EdgeInsets.all(widget.padding ?? 0),
                    child: FilterChip(
                      label: Text(
                        widget.labels[index],
                        style: TextStyle(
                          color: isSelected
                              ? (widget.selectedTextColor ?? Colors.white)
                              : (widget.unselectedTextColor ?? Colors.black),
                          fontSize: widget.fontSize ?? 14.0,
                        ),
                      ),
                      selected: isSelected,
                      onSelected: (_) => _toggleSelection(widget.values[index]),
                      selectedColor:
                          widget.selectedColor ?? Theme.of(context).primaryColor,
                      backgroundColor: widget.unselectedColor ?? Colors.grey[200],
                      shape: RoundedRectangleBorder(
                        borderRadius:
                            BorderRadius.circular(widget.borderRadius ?? 16.0),
                        side: BorderSide(
                          width: 0,
                          color: Colors.transparent,
                        ),
                      ),
                      showCheckmark: false,
                    ),
                  );
                },
              ),
            ),
          ],
        );
      }
    }

    🛠 Customizing for Strings (Optional)

    Currently, values are expected as List<int>, but if you need both labels and values as List<String>, you can easily modify the widget or ask ChatGPT to convert it for you.

3