ashlarui

Radio

A choice of one, in Flutter's RadioGroup, with labels.

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

import 'ui/radio.dart';

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

  @override
  State<RadioDemo> createState() => _RadioDemoState();
}

class _RadioDemoState extends State<RadioDemo> {
  String? plan = 'monthly';

  @override
  Widget build(BuildContext context) => RadioGroup<String>(
    groupValue: plan,
    onChanged: (v) => setState(() => plan = v),
    child: const Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Radio(value: 'monthly', label: Text('Monthly'), description: Text('KES 500 a month.')),
        SizedBox(height: 12),
        Radio(value: 'yearly', label: Text('Yearly'), description: Text('Two months free.')),
        SizedBox(height: 12),
        Radio(value: 'team', label: Text('Team'), enabled: false),
      ],
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add radio

It writes theme.dart, palette.dart and radio.dart into lib/ui, keeping any you already have.

Examples

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

As cards

Each option a card to pick.

As cards, as it looks in an app As cards, as it looks in an app, dark
// As cards: Each option a card to pick.
import 'package:flutter/widgets.dart';

import 'ui/radio.dart';

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

  @override
  State<RadioCardsExample> createState() => _RadioCardsExampleState();
}

class _RadioCardsExampleState extends State<RadioCardsExample> {
  String? plan = 'pro';

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 320,
    child: RadioGroup<String>(
      groupValue: plan,
      onChanged: (v) => setState(() => plan = v),
      child: const Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Radio(value: 'basic', card: true, label: Text('Basic'), description: Text('KES 500 a month.')),
          SizedBox(height: 12),
          Radio(value: 'pro', card: true, label: Text('Pro'), description: Text('KES 1,500 a month.')),
        ],
      ),
    ),
  );
}

Wrong

Marked until one is chosen.

Wrong, as it looks in an app Wrong, as it looks in an app, dark
// Wrong: Marked until one is chosen.
import 'package:flutter/widgets.dart';

import 'ui/radio.dart';

class RadioErrorExample extends StatelessWidget {
  const RadioErrorExample({super.key});

  @override
  Widget build(BuildContext context) => RadioGroup<String>(
    groupValue: null,
    onChanged: (_) {},
    child: const Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Radio(value: 'a', label: Text('Card'), error: true),
        SizedBox(height: 8),
        Radio(value: 'b', label: Text('M-Pesa'), error: true),
      ],
    ),
  );
}

API

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

Radioclass

One choice in a RadioGroup. With a label the whole row picks it, and a screen reader hears the label.

Parameter Type Default What it is for
valuerequired T The value it stands for, given back when it is chosen.
labelWidget?Beside the radio button.
description Widget? A line under the label.
enabled bool true False fades it and stops it taking presses and focus.
focusNode FocusNode? Its focus node, to move focus to it or follow it from outside; one is made when not given.
autofocus bool false Takes the keyboard's focus when it first appears.
card bool false Drawn as a card to choose: a border round it, marked when chosen.
error bool false Draws it as invalid.

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/radio.dart 186 lines
// Ashlar UI: Radio. Copied into your app by `ashlar add radio`; it is yours to change.
//
// Flutter's widgets layer has the group: RadioGroup holds the chosen value, and moves the
// choice with the arrow keys. This file draws the radios inside it.

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// One choice in a [RadioGroup]. With a [label] the whole row picks it, and a screen
/// reader hears the label.
///
/// ```dart
/// RadioGroup<String>(
///   groupValue: plan,
///   onChanged: (v) => setState(() => plan = v),
///   child: Column(children: [
///     Radio(value: 'monthly', label: Text('Monthly')),
///     Radio(value: 'yearly', label: Text('Yearly'), description: Text('Two months free.')),
///   ]),
/// )
/// ```
class Radio<T> extends StatefulWidget {
  const Radio({
    super.key,
    required this.value,
    this.label,
    this.description,
    this.enabled = true,
    this.focusNode,
    this.autofocus = false,
    this.card = false,
    this.error = false,
  });

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

  /// Beside the radio button.
  final Widget? label;

  /// A line under the label.
  final Widget? description;

  /// False fades it and stops it taking presses and focus.
  final bool enabled;

  /// Its focus node, to move focus to it or follow it from outside; one is made when not
  /// given.
  final FocusNode? focusNode;

  /// Takes the keyboard's focus when it first appears.
  final bool autofocus;

  /// Drawn as a card to choose: a border round it, marked when chosen.
  final bool card;

  /// Draws it as invalid.
  final bool error;

  @override
  State<Radio<T>> createState() => _RadioState<T>();
}

class _RadioState<T> extends State<Radio<T>> {
  FocusNode? _ownFocus;
  FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());

  @override
  void dispose() {
    _ownFocus?.dispose();
    super.dispose();
  }

  // Its own size, at the start, even where its parent stretches what it holds; a card
  // fills the width it is given.
  @override
  Widget build(BuildContext context) => widget.card
      ? _control(context)
      : Align(
          alignment: AlignmentDirectional.centerStart,
          widthFactor: 1,
          heightFactor: 1,
          child: _control(context),
        );

  Widget _control(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final registry = RadioGroup.maybeOf<T>(context);
    return RawRadio<T>(
      value: widget.value,
      mouseCursor: WidgetStateProperty.resolveWith(
        (s) => s.contains(WidgetState.disabled)
            ? SystemMouseCursors.basic
            : SystemMouseCursors.click,
      ),
      toggleable: false,
      focusNode: _focus,
      autofocus: widget.autofocus,
      groupRegistry: registry,
      enabled: widget.enabled && registry != null,
      builder: (context, state) {
        final chosen = state.value ?? false;
        final focused = state.states.contains(WidgetState.focused);
        final enabled = !state.states.contains(WidgetState.disabled);
        final dot = AnimatedContainer(
          duration: t.duration,
          width: 16,
          height: 16,
          decoration: BoxDecoration(
            // Opaque, so the shadow beneath does not show through.
            color: c.background,
            shape: BoxShape.circle,
            border: Border.all(
              color: widget.error
                  ? c.destructive
                  : (focused ? c.ring : c.input),
            ),
            boxShadow: [if (focused) ...t.focusRing, ...t.shadowXs],
          ),
          alignment: Alignment.center,
          child: AnimatedScale(
            duration: t.duration,
            scale: chosen ? 1 : 0,
            child: Container(
              width: 8,
              height: 8,
              decoration: BoxDecoration(
                color: c.primary,
                shape: BoxShape.circle,
              ),
            ),
          ),
        );
        final body = widget.label == null && widget.description == null
            ? dot
            : Row(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(padding: const EdgeInsets.only(top: 2), child: dot),
                  const SizedBox(width: 12),
                  Flexible(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        if (widget.label != null)
                          DefaultTextStyle.merge(
                            style: t.text.sm.copyWith(
                              color: c.foreground,
                              fontWeight: FontWeight.w500,
                            ),
                            child: widget.label!,
                          ),
                        if (widget.description != null)
                          DefaultTextStyle.merge(
                            style: t.text.sm.copyWith(color: c.mutedForeground),
                            child: widget.description!,
                          ),
                      ],
                    ),
                  ),
                ],
              );
        return Opacity(
          opacity: enabled ? 1 : .5,
          child: widget.card
              ? AnimatedContainer(
                  duration: t.duration,
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: chosen
                        ? c.primary.withValues(alpha: .05)
                        : c.primary.withValues(alpha: 0),
                    borderRadius: BorderRadius.circular(t.radiusMd),
                    border: Border.all(color: chosen ? c.primary : c.border),
                  ),
                  child: body,
                )
              : body,
        );
      },
    );
  }
}