ashlarui

Toggle Group

Toggles joined into one piece, one or several pressed.

Toggle Group, as it looks in an app Toggle Group, as it looks in an app, dark
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/toggle_group.dart';

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

  @override
  State<ToggleGroupDemo> createState() => _ToggleGroupDemoState();
}

class _ToggleGroupDemoState extends State<ToggleGroupDemo> {
  Set<String> styles = {'bold'};
  Set<String> align = {'left'};

  @override
  Widget build(BuildContext context) => Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      ToggleGroup<String>(
        multiple: true,
        semanticLabel: 'Text style',
        values: styles,
        onChanged: (v) => setState(() => styles = v),
        items: const [
          ToggleGroupItem('bold', AshlarIcon(AshlarIconsRoundedStroke.bold), semanticLabel: 'Bold'),
          ToggleGroupItem('italic', AshlarIcon(AshlarIconsRoundedStroke.italic), semanticLabel: 'Italic'),
          ToggleGroupItem('underline', AshlarIcon(AshlarIconsRoundedStroke.underline), semanticLabel: 'Underline'),
        ],
      ),
      const SizedBox(height: 16),
      ToggleGroup<String>(
        semanticLabel: 'Alignment',
        values: align,
        onChanged: (v) => setState(() => align = v),
        items: const [
          ToggleGroupItem('left', AshlarIcon(AshlarIconsRoundedStroke.textAlignStart), semanticLabel: 'Align left'),
          ToggleGroupItem('center', AshlarIcon(AshlarIconsRoundedStroke.textAlignCenter), semanticLabel: 'Centre'),
          ToggleGroupItem('right', AshlarIcon(AshlarIconsRoundedStroke.textAlignEnd), semanticLabel: 'Align right'),
        ],
      ),
    ],
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Toggle Group with controls, as it looks in an app Toggle Group with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add toggle-group

It writes theme.dart, palette.dart, group.dart, pressable.dart, toggle.dart and toggle_group.dart into lib/ui, keeping any you already have. It needs toggle and group, which come with it.

Examples

Other ways to use it, each running live, with its code.

Several at once

Bold, italic and underline together.

Several at once, as it looks in an app Several at once, as it looks in an app, dark
// Several at once: Bold, italic and underline together.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/toggle_group.dart';

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

  @override
  State<ToggleGroupMultipleExample> createState() => _ToggleGroupMultipleExampleState();
}

class _ToggleGroupMultipleExampleState extends State<ToggleGroupMultipleExample> {
  var on = {'bold'};

  @override
  Widget build(BuildContext context) => ToggleGroup<String>(
    multiple: true,
    values: on,
    semanticLabel: 'Text style',
    onChanged: (v) => setState(() => on = v),
    items: const [
      ToggleGroupItem('bold', AshlarIcon(AshlarIconsRoundedStroke.bold), semanticLabel: 'Bold'),
      ToggleGroupItem('italic', AshlarIcon(AshlarIconsRoundedStroke.italic), semanticLabel: 'Italic'),
      ToggleGroupItem('underline', AshlarIcon(AshlarIconsRoundedStroke.underline), semanticLabel: 'Underline'),
    ],
  );
}

Spaced

Apart, each with its own border.

Spaced, as it looks in an app Spaced, as it looks in an app, dark
// Spaced: Apart, each with its own border.
import 'package:flutter/widgets.dart';

import 'ui/toggle_group.dart';

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

  @override
  State<ToggleGroupSpacedExample> createState() => _ToggleGroupSpacedExampleState();
}

class _ToggleGroupSpacedExampleState extends State<ToggleGroupSpacedExample> {
  var view = 'week';

  @override
  Widget build(BuildContext context) => ToggleGroup<String>(
    spacing: 8,
    values: {view},
    onChanged: (v) => setState(() => view = v.firstOrNull ?? view),
    items: const [
      ToggleGroupItem('day', Text('Day')),
      ToggleGroupItem('week', Text('Week')),
      ToggleGroupItem('month', Text('Month')),
    ],
  );
}

Upright

Stacked, the arrows moving up and down.

Upright, as it looks in an app Upright, as it looks in an app, dark
// Upright: Stacked, the arrows moving up and down.
import 'package:flutter/widgets.dart';

import 'ui/toggle_group.dart';

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

  @override
  State<ToggleGroupVerticalExample> createState() => _ToggleGroupVerticalExampleState();
}

class _ToggleGroupVerticalExampleState extends State<ToggleGroupVerticalExample> {
  var align = 'top';

  @override
  Widget build(BuildContext context) => ToggleGroup<String>(
    axis: Axis.vertical,
    values: {align},
    semanticLabel: 'Alignment',
    onChanged: (v) => setState(() => align = v.firstOrNull ?? align),
    items: const [
      ToggleGroupItem('top', Text('Top')),
      ToggleGroupItem('middle', Text('Middle')),
      ToggleGroupItem('bottom', Text('Bottom')),
    ],
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

ToggleGroupItemclass

One choice in a ToggleGroup.

Parameter Type Default What it is for
valuepositional T The value it stands for, given back when it is pressed.
childpositional Widget Its label or icon.
semanticLabel String? What a screen reader calls it, for an item that shows an icon alone.
enabled bool true False fades it and leaves it out of the group's choices.

ToggleGroupclass

Toggles joined into one piece. Without multiple, pressing one releases the rest, and pressing the pressed one releases it. The arrow keys move between them.

Parameter Type Default What it is for
valuesrequired Set<T> The pressed ones.
onChangedrequired ValueChanged<Set<T>>? Null disables the group.
itemsrequired List<ToggleGroupItem<T>> The toggles, in order.
multiple bool false Several may be on at once, not one.
variant ToggleVariant ToggleVariant.outline Plain, or with borders all the time.
size ToggleSize ToggleSize.md Small, medium or large.
semanticLabel String? What the group sets, read before it: 'Text alignment'.
spacing double 0 The gap between toggles. None joins them into one piece; any sets them apart, each with all its corners round.
axis Axis Axis.horizontal Vertical stacks them, the up and down arrows moving between them.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/toggle_group.dart 157 lines
// Ashlar UI: Toggle Group. Copied into your app by `ashlar add toggle-group`; it is yours to
// change.

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'group.dart';
import 'theme.dart';
import 'toggle.dart';

/// One choice in a [ToggleGroup].
@immutable
class ToggleGroupItem<T> {
  const ToggleGroupItem(
    this.value,
    this.child, {
    this.semanticLabel,
    this.enabled = true,
  });

  /// The value it stands for, given back when it is pressed.
  final T value;

  /// Its label or icon.
  final Widget child;

  /// What a screen reader calls it, for an item that shows an icon alone.
  final String? semanticLabel;

  /// False fades it and leaves it out of the group's choices.
  final bool enabled;
}

/// Toggles joined into one piece. Without [multiple], pressing one releases the rest, and
/// pressing the pressed one releases it. The arrow keys move between them.
///
/// ```dart
/// ToggleGroup<TextAlign>(
///   values: {align},
///   onChanged: (v) => setState(() => align = v.firstOrNull ?? TextAlign.left),
///   items: [
///     ToggleGroupItem(TextAlign.left, AshlarIcon(AshlarIconsRoundedStroke.textAlignStart), semanticLabel: 'Align left'),
///     ToggleGroupItem(TextAlign.center, AshlarIcon(AshlarIconsRoundedStroke.textAlignCenter), semanticLabel: 'Centre'),
///   ],
/// )
/// ```
class ToggleGroup<T> extends StatelessWidget {
  const ToggleGroup({
    super.key,
    required this.values,
    required this.onChanged,
    required this.items,
    this.multiple = false,
    this.variant = ToggleVariant.outline,
    this.size = ToggleSize.md,
    this.semanticLabel,
    this.spacing = 0,
    this.axis = Axis.horizontal,
  });

  /// The pressed ones.
  final Set<T> values;

  /// Null disables the group.
  final ValueChanged<Set<T>>? onChanged;

  /// The toggles, in order.
  final List<ToggleGroupItem<T>> items;

  /// Several may be on at once, not one.
  final bool multiple;

  /// Plain, or with borders all the time.
  final ToggleVariant variant;

  /// Small, medium or large.
  final ToggleSize size;

  /// What the group sets, read before it: 'Text alignment'.
  final String? semanticLabel;

  /// The gap between toggles. None joins them into one piece; any sets them apart, each
  /// with all its corners round.
  final double spacing;

  /// Vertical stacks them, the up and down arrows moving between them.
  final Axis axis;

  Widget _layout(List<Widget> toggles) {
    if (spacing == 0) {
      final joined = JoinedGroup(axis: axis, children: toggles);
      return axis == Axis.horizontal
          ? IntrinsicHeight(child: joined)
          : IntrinsicWidth(child: joined);
    }
    return Flex(
      direction: axis,
      mainAxisSize: MainAxisSize.min,
      children: [
        for (final (i, toggle) in toggles.indexed) ...[
          if (i > 0)
            SizedBox(
              width: axis == Axis.horizontal ? spacing : 0,
              height: axis == Axis.vertical ? spacing : 0,
            ),
          toggle,
        ],
      ],
    );
  }

  void _press(T value, bool on) {
    final next = multiple ? {...values} : <T>{};
    if (on) {
      next.add(value);
    } else {
      next.remove(value);
    }
    onChanged?.call(next);
  }

  @override
  Widget build(BuildContext context) => Semantics(
    container: true,
    label: semanticLabel,
    child: Shortcuts(
      // Right is next in left-to-right text, and back in right-to-left.
      shortcuts: {
        SingleActivator(LogicalKeyboardKey.arrowRight): isRtl(context)
            ? const PreviousFocusIntent()
            : const NextFocusIntent(),
        const SingleActivator(LogicalKeyboardKey.arrowDown):
            const NextFocusIntent(),
        SingleActivator(LogicalKeyboardKey.arrowLeft): isRtl(context)
            ? const NextFocusIntent()
            : const PreviousFocusIntent(),
        const SingleActivator(LogicalKeyboardKey.arrowUp):
            const PreviousFocusIntent(),
      },
      child: FocusTraversalGroup(
        child: _layout([
          for (final item in items)
            Toggle(
              pressed: values.contains(item.value),
              onChanged: onChanged == null || !item.enabled
                  ? null
                  : (on) => _press(item.value, on),
              variant: variant,
              size: size,
              semanticLabel: item.semanticLabel,
              child: item.child,
            ),
        ]),
      ),
    ),
  );
}