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 asList<String>
, you can easily modify the widget or ask ChatGPT to convert it for you.