ashlarui

Accordion

Sections that open one at a time under their headings.

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

import 'ui/accordion.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 420,
    child: Accordion(
      initiallyOpen: {0},
      items: [
        AccordionItem(
          title: Text('Can I change the components?'),
          content: Text('Yes. Each one is a file in your app, so you can change anything in it.'),
        ),
        AccordionItem(
          title: Text('Does it use Material?'),
          content: Text('No. Only Flutter\'s widgets layer, so nothing of Material comes along.'),
        ),
        AccordionItem(
          title: Text('Does it work with a keyboard?'),
          content: Text('Yes. Tab to a heading, then Enter or Space to open it.'),
        ),
      ],
    ),
  );
}

Try its options

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

Accordion with controls, as it looks in an app Accordion with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add accordion

It writes theme.dart, palette.dart, group.dart, pressable.dart, collapsible.dart and accordion.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, collapsible and ashlar_icons, which come with it.

Examples

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

Bordered, several open

Each in a box; any may be open.

Bordered, several open, as it looks in an app Bordered, several open, as it looks in an app, dark
// Bordered, several open: Each in a box; any may be open.
import 'package:flutter/widgets.dart';

import 'ui/accordion.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 420,
    child: Accordion(
      bordered: true,
      multiple: true,
      initiallyOpen: {0},
      items: [
        AccordionItem(title: Text('Is it accessible?'), content: Text('Yes: keyboard and screen readers.')),
        AccordionItem(title: Text('Is it styled?'), content: Text('Yes, and the code is yours to change.')),
        AccordionItem(title: Text('Can it be disabled?'), content: Text('An item can be.'), enabled: false),
      ],
    ),
  );
}

API

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

AccordionItemclass

One section of an Accordion: its heading and what opens under it.

Parameter Type Default What it is for
titlerequired Widget The heading that opens and closes it.
contentrequired Widget What shows when it is open.
enabled bool true False keeps it shut, its heading faded.

Accordionclass

Sections stacked one above the other, each opening under its heading. One open at a time, or any number with multiple. A screen reader hears each heading as a button that is expanded or collapsed.

Parameter Type Default What it is for
itemsrequired List<AccordionItem> The items, top to bottom.
multiple bool false Several may be open at once, not one.
initiallyOpen Set<int> const {} The sections open at first, by index.
bordered bool false In a box with rounded corners, the open section on a muted ground.

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/accordion.dart 166 lines
// Ashlar UI: Accordion. Copied into your app by `ashlar add accordion`; it is yours to change.

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

import 'collapsible.dart';
import 'pressable.dart';
import 'theme.dart';

/// One section of an [Accordion]: its heading and what opens under it.
@immutable
class AccordionItem {
  const AccordionItem({
    required this.title,
    required this.content,
    this.enabled = true,
  });

  /// The heading that opens and closes it.
  final Widget title;

  /// What shows when it is open.
  final Widget content;

  /// False keeps it shut, its heading faded.
  final bool enabled;
}

/// Sections stacked one above the other, each opening under its heading. One open at a
/// time, or any number with [multiple]. A screen reader hears each heading as a button
/// that is expanded or collapsed.
///
/// ```dart
/// Accordion(items: [
///   AccordionItem(title: Text('Can I change the components?'), content: Text('Yes. They are files in your app.')),
///   AccordionItem(title: Text('Does it use Material?'), content: Text('No. Only Flutter\'s widgets layer.')),
/// ])
/// ```
class Accordion extends StatefulWidget {
  const Accordion({
    super.key,
    required this.items,
    this.multiple = false,
    this.initiallyOpen = const {},
    this.bordered = false,
  });

  /// The items, top to bottom.
  final List<AccordionItem> items;

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

  /// The sections open at first, by index.
  final Set<int> initiallyOpen;

  /// In a box with rounded corners, the open section on a muted ground.
  final bool bordered;

  @override
  State<Accordion> createState() => _AccordionState();
}

class _AccordionState extends State<Accordion> {
  late final Set<int> _open = {...widget.initiallyOpen};

  void _toggle(int i) => setState(() {
    if (_open.contains(i)) {
      _open.remove(i);
    } else {
      if (!widget.multiple) _open.clear();
      _open.add(i);
    }
  });

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final pad = widget.bordered ? 16.0 : 0.0;
    final column = Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        for (final (i, item) in widget.items.indexed)
          AnimatedContainer(
            duration: t.duration,
            padding: EdgeInsets.symmetric(horizontal: pad),
            decoration: BoxDecoration(
              color: widget.bordered && _open.contains(i)
                  ? c.muted.withValues(alpha: c.muted.a * .5)
                  : c.muted.withValues(alpha: 0),
              border: Border(
                bottom: BorderSide(
                  color: i == widget.items.length - 1
                      ? const Color(0x00000000)
                      : c.border,
                ),
              ),
            ),
            child: Collapsible(
              open: _open.contains(i),
              builder: (context, open, _) => Pressable(
                onPressed: item.enabled ? () => _toggle(i) : null,
                expanded: open,
                builder: (context, state) => AnimatedContainer(
                  duration: t.duration,
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(t.radiusMd),
                    boxShadow: [if (state.focused) ...t.focusRing],
                  ),
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Expanded(
                        child: DefaultTextStyle.merge(
                          style: t.text.sm.copyWith(
                            color: item.enabled
                                ? c.foreground
                                : c.mutedForeground,
                            fontWeight: FontWeight.w500,
                            decoration: state.hovered && item.enabled
                                ? TextDecoration.underline
                                : null,
                            decorationColor: c.foreground,
                          ),
                          child: item.title,
                        ),
                      ),
                      const SizedBox(width: 16),
                      AnimatedRotation(
                        turns: open ? .5 : 0,
                        duration: t.duration,
                        child: AshlarIcon(
                          AshlarIconsRoundedStroke.chevronDown,
                          size: 16,
                          color: c.mutedForeground,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
              child: Padding(
                padding: const EdgeInsets.only(bottom: 16),
                child: DefaultTextStyle.merge(
                  style: TextStyle(color: c.foreground),
                  child: item.content,
                ),
              ),
            ),
          ),
      ],
    );
    if (!widget.bordered) return column;
    return Container(
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(t.radiusLg),
        border: Border.all(color: c.border),
      ),
      child: column,
    );
  }
}