skills/material3-expressive-flutter/references/components/button-groups.md

7.3 KiB

Button Groups - M3 Expressive Component

Overview

Button groups are NEW in Material 3 Expressive. They allow users to select one or more options from a related set of choices with consistent styling.

Design Principles

  • Related Actions: Group buttons that perform similar or related functions
  • Clear Selection States: Active/inactive states must be immediately obvious
  • Consistent Spacing: Buttons should be visually connected but distinguishable

Implementation in Flutter

Basic Button Group (Custom Implementation)

class ButtonGroup extends StatefulWidget {
  final List<String> options;
  final List<bool> initialSelection;
  final ValueChanged<List<bool>> onSelectionChanged;
  final bool multiSelect;

  const ButtonGroup({
    super.key,
    required this.options,
    required this.initialSelection,
    required this.onSelectionChanged,
    this.multiSelect = false,
  });

  @override
  State<ButtonGroup> createState() => _ButtonGroupState();
}

class _ButtonGroupState extends State<ButtonGroup> {
  late List<bool> _selection;

  @override
  void initState() {
    super.initState();
    _selection = List.from(widget.initialSelection);
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return Row(
      mainAxisSize: MainAxisSize.min,
      children: List.generate(widget.options.length, (index) {
        final isFirst = index == 0;
        final isLast = index == widget.options.length - 1;
        final isSelected = _selection[index];

        return Container(
          decoration: BoxDecoration(
            border: Border.all(
              color: colorScheme.outline,
              width: 1,
            ),
            borderRadius: BorderRadius.horizontal(
              left: isFirst ? const Radius.circular(100) : Radius.zero,
              right: isLast ? const Radius.circular(100) : Radius.zero,
            ),
            color: isSelected
                ? colorScheme.secondaryContainer
                : colorScheme.surface,
          ),
          child: Material(
            color: Colors.transparent,
            child: InkWell(
              onTap: () {
                setState(() {
                  if (widget.multiSelect) {
                    _selection[index] = !_selection[index];
                  } else {
                    _selection = List.generate(
                      widget.options.length,
                      (i) => i == index,
                    );
                  }
                });
                widget.onSelectionChanged(_selection);
              },
              borderRadius: BorderRadius.horizontal(
                left: isFirst ? const Radius.circular(100) : Radius.zero,
                right: isLast ? const Radius.circular(100) : Radius.zero,
              ),
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  horizontal: 24,
                  vertical: 10,
                ),
                child: Text(
                  widget.options[index],
                  style: theme.textTheme.labelLarge?.copyWith(
                    color: isSelected
                        ? colorScheme.onSecondaryContainer
                        : colorScheme.onSurface,
                  ),
                ),
              ),
            ),
          ),
        );
      }),
    );
  }
}

Usage Example

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<bool> _selection = [true, false, false];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Button Groups')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Single select
            ButtonGroup(
              options: const ['Option 1', 'Option 2', 'Option 3'],
              initialSelection: _selection,
              onSelectionChanged: (selection) {
                setState(() => _selection = selection);
              },
            ),
            const SizedBox(height: 24),
            // Multi select
            ButtonGroup(
              options: const ['Coffee', 'Tea', 'Juice'],
              initialSelection: [false, false, false],
              multiSelect: true,
              onSelectionChanged: (selection) {
                print('Multi-select: $selection');
              },
            ),
          ],
        ),
      ),
    );
  }
}

With Icons

class IconButtonGroup extends StatelessWidget {
  final List<IconData> icons;
  final List<String> labels;
  final List<bool> selection;
  final ValueChanged<int> onPressed;

  const IconButtonGroup({
    super.key,
    required this.icons,
    required this.labels,
    required this.selection,
    required this.onPressed,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: List.generate(icons.length, (index) {
        return _buildIconButton(context, index);
      }),
    );
  }

  Widget _buildIconButton(BuildContext context, int index) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final isSelected = selection[index];

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4),
      child: FilterChip(
        selected: isSelected,
        label: Row(
          children: [
            Icon(
              icons[index],
              size: 18,
              color: isSelected
                  ? colorScheme.onSecondaryContainer
                  : colorScheme.onSurface,
            ),
            const SizedBox(width: 8),
            Text(labels[index]),
          ],
        ),
        onSelected: (_) => onPressed(index),
      ),
    );
  }
}

Best Practices

  1. Limit Options: Keep button groups to 2-5 options for clarity
  2. Equal Importance: Only use when options have roughly equal importance
  3. Short Labels: Keep text concise (1-2 words ideal)
  4. Accessible Touch Targets: Minimum 48x48 dp hit areas
  5. Visual Feedback: Show hover/press states for better UX

Accessibility

// Add semantics for screen readers
Semantics(
  button: true,
  label: 'Select ${widget.options[index]}',
  selected: isSelected,
  child: // ... button widget
)

Android Compose Reference

For comparison, here's how it looks in Jetpack Compose (M3E is implemented):

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpressiveButtonGroup() {
    val checked = remember { mutableStateListOf(false, false, false) }
    
    ButtonGroup(
        modifier = Modifier.padding(8.dp),
        overflowIndicator = {}
    ) {
        options.forEachIndexed { index, label ->
            toggleableItem(
                checked = checked[index],
                onCheckedChange = { checked[index] = it },
                label = label,
                icon = { Icon(icons[index], null) }
            )
        }
    }
}
  • Split Buttons: For primary + secondary action pairs
  • Segmented Buttons: Flutter's SegmentedButton widget (similar but not M3E)
  • Toggle Buttons: Use ToggleButtons for simpler cases