ashlarui

Tabs

Views of one thing, one at a time, chosen from a row of tabs.

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

import 'ui/button.dart';
import 'ui/card.dart';
import 'ui/field.dart';
import 'ui/input.dart';
import 'ui/tabs.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 380,
    child: Tabs(
      expand: true,
      tabs: [
        TabItem(
          label: const Text('Account'),
          content: _form(
            'Account',
            'Change your name here.',
            const Field(
              label: Text('Name'),
              child: Input(placeholder: 'Wanjiru Mwangi'),
            ),
          ),
        ),
        TabItem(
          label: const Text('Password'),
          content: _form(
            'Password',
            'Change your password here.',
            const Field(label: Text('New password'), child: Input(obscureText: true)),
          ),
        ),
      ],
    ),
  );

  Widget _form(String title, String description, Widget field) => Card(
    children: [
      CardHeader(title: Text(title), description: Text(description)),
      CardContent(child: field),
      CardFooter(
        children: [Button(onPressed: () {}, child: const Text('Save'))],
      ),
    ],
  );
}

Try its options

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

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

Add it

In your Flutter project:

$ ashlar add tabs

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

Examples

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

Underlined

A line under the chosen tab.

Underlined, as it looks in an app Underlined, as it looks in an app, dark
// Underlined: A line under the chosen tab.
import 'package:flutter/widgets.dart';

import 'ui/tabs.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 400,
    child: Tabs(
      variant: TabsVariant.line,
      tabs: [
        TabItem(
          label: Text('Overview'),
          content: Padding(padding: EdgeInsets.only(top: 12), child: Text('The project at a glance.')),
        ),
        TabItem(
          label: Text('Activity'),
          content: Padding(padding: EdgeInsets.only(top: 12), child: Text('What changed, and who changed it.')),
        ),
        TabItem(
          label: Text('Settings'),
          content: Padding(padding: EdgeInsets.only(top: 12), child: Text('Who can see it.')),
        ),
      ],
    ),
  );
}

Upright

Tabs down the side.

Upright, as it looks in an app Upright, as it looks in an app, dark
// Upright: Tabs down the side.
import 'package:flutter/widgets.dart';

import 'ui/tabs.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 420,
    child: Tabs(
      axis: Axis.vertical,
      tabs: [
        TabItem(label: Text('Account'), content: Text('Your name and email.')),
        TabItem(label: Text('Password'), content: Text('Change your password.')),
        TabItem(label: Text('Billing'), content: Text('Your plan and invoices.'), enabled: false),
      ],
    ),
  );
}

API

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

TabItemclass

One tab: its label in the list and the content shown when it is chosen.

Parameter Type Default What it is for
labelrequired Widget The tab's name.
contentrequired Widget What shows when the tab is chosen.
icon Widget? A 16px icon before the label.
enabled bool true False fades the tab and stops it being chosen.

TabsVariantenum

How the tabs look: on a muted bar with the chosen one raised, or as a line of words with the chosen one underlined.

ValueWhat it is for
pill
line

Tabsclass

Views of one thing, one at a time, chosen from a row of tabs. Tab moves into the row and out again; the arrow keys move along it, choosing as they go.

Give index and onChanged to hold the choice yourself; leave them out and the tabs hold it, starting at initialIndex.

Parameter Type Default What it is for
tabsrequired List<TabItem> The tabs, in order.
index int? Which tab is chosen, when you hold its state.
onChanged ValueChanged<int>? Told the tab chosen.
initialIndex int 0 Which tab is chosen first, when nothing holds its state.
expand bool false Stretches the row of tabs across the width it is given.
variant TabsVariant TabsVariant.pill A pill on a muted track, or a line under the chosen tab.
axis Axis Axis.horizontal Vertical sets the tabs in a column beside the panel; the up and down arrows move.

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

import 'dart:ui' show SemanticsRole;

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

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

/// One tab: its [label] in the list and the [content] shown when it is chosen.
@immutable
class TabItem {
  const TabItem({
    required this.label,
    required this.content,
    this.icon,
    this.enabled = true,
  });

  /// The tab's name.
  final Widget label;

  /// What shows when the tab is chosen.
  final Widget content;

  /// A 16px icon before the label.
  final Widget? icon;

  /// False fades the tab and stops it being chosen.
  final bool enabled;
}

/// How the tabs look: on a muted bar with the chosen one raised, or as a line of words
/// with the chosen one underlined.
enum TabsVariant { pill, line }

/// Views of one thing, one at a time, chosen from a row of tabs. Tab moves into the row
/// and out again; the arrow keys move along it, choosing as they go.
///
/// Give [index] and [onChanged] to hold the choice yourself; leave them out and the tabs
/// hold it, starting at [initialIndex].
///
/// ```dart
/// Tabs(tabs: [
///   TabItem(label: Text('Account'), content: AccountForm()),
///   TabItem(label: Text('Password'), content: PasswordForm()),
/// ])
/// ```
class Tabs extends StatefulWidget {
  const Tabs({
    super.key,
    required this.tabs,
    this.index,
    this.onChanged,
    this.initialIndex = 0,
    this.expand = false,
    this.variant = TabsVariant.pill,
    this.axis = Axis.horizontal,
  });

  /// The tabs, in order.
  final List<TabItem> tabs;

  /// Which tab is chosen, when you hold its state.
  final int? index;

  /// Told the tab chosen.
  final ValueChanged<int>? onChanged;

  /// Which tab is chosen first, when nothing holds its state.
  final int initialIndex;

  /// Stretches the row of tabs across the width it is given.
  final bool expand;

  /// A pill on a muted track, or a line under the chosen tab.
  final TabsVariant variant;

  /// Vertical sets the tabs in a column beside the panel; the up and down arrows move.
  final Axis axis;

  @override
  State<Tabs> createState() => _TabsState();
}

class _TabsState extends State<Tabs> {
  late int _own = widget.initialIndex;
  final List<FocusNode> _nodes = [];

  int get _index => widget.index ?? _own;

  void _choose(int i, {bool focus = false}) {
    if (!widget.tabs[i].enabled) return;
    if (widget.index == null) setState(() => _own = i);
    widget.onChanged?.call(i);
    if (focus) _nodes[i].requestFocus();
  }

  /// The next enabled tab from the chosen one, [step] along, wrapping round.
  void _move(int step) {
    // Across, the arrows follow the reading direction.
    if (widget.axis == Axis.horizontal && isRtl(context)) step = -step;
    final n = widget.tabs.length;
    for (var k = 1; k <= n; k++) {
      final i = ((_index + step * k) % n + n) % n;
      if (widget.tabs[i].enabled) return _choose(i, focus: true);
    }
  }

  @override
  void dispose() {
    for (final n in _nodes) {
      n.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final dark = t.brightness == Brightness.dark;
    while (_nodes.length < widget.tabs.length) {
      _nodes.add(FocusNode());
    }
    // Only the chosen tab is in the Tab order; the arrow keys reach the rest.
    for (final (i, n) in _nodes.indexed) {
      n.skipTraversal = i != _index;
    }
    final triggers = [
      for (final (i, tab) in widget.tabs.indexed)
        Pressable(
          onPressed: tab.enabled ? () => _choose(i) : null,
          focusNode: _nodes[i],
          role: SemanticsRole.tab,
          selected: i == _index,
          builder: (context, state) {
            final active = i == _index;
            final line = widget.variant == TabsVariant.line;
            final across = widget.axis == Axis.horizontal;
            final fg = active || (!dark && !line)
                ? c.foreground
                : c.mutedForeground;
            if (line) {
              // A word, underlined when chosen: along the bottom, or down the start side.
              final mark = BorderSide(
                color: active
                    ? c.foreground
                    : c.foreground.withValues(alpha: 0),
                width: 2,
              );
              return Opacity(
                opacity: state.enabled ? 1 : .5,
                child: AnimatedContainer(
                  duration: t.duration,
                  // As tall as it is drawn, or taller when larger text needs it.
                  height: grownHeight(context, t.control(36)),
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  alignment: across
                      ? Alignment.center
                      : AlignmentDirectional.centerStart,
                  decoration: BoxDecoration(
                    border: across
                        ? Border(bottom: mark)
                        : BorderDirectional(start: mark),
                    boxShadow: [if (state.focused) ...t.focusRing],
                  ),
                  child: DefaultTextStyle.merge(
                    style: t.text.sm.copyWith(
                      color: state.hovered && !active ? c.foreground : fg,
                      fontWeight: FontWeight.w500,
                    ),
                    maxLines: 1,
                    child: IconTheme.merge(
                      data: IconThemeData(color: fg, size: 16),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          if (tab.icon != null) ...[
                            tab.icon!,
                            const SizedBox(width: 6),
                          ],
                          Flexible(child: tab.label),
                        ],
                      ),
                    ),
                  ),
                ),
              );
            }
            return Opacity(
              opacity: state.enabled ? 1 : .5,
              child: AnimatedContainer(
                duration: t.duration,
                // As tall as it is drawn, or taller when larger text needs it.
                height: grownHeight(context, 29),
                padding: const EdgeInsets.symmetric(horizontal: 8),
                alignment: across
                    ? Alignment.center
                    : AlignmentDirectional.centerStart,
                decoration: BoxDecoration(
                  color: active
                      ? (dark
                            ? c.input.withValues(alpha: c.input.a * .3)
                            : c.background)
                      : (dark ? c.input : c.background).withValues(alpha: 0),
                  borderRadius: BorderRadius.circular(t.radiusMd),
                  border: Border.all(
                    color: active && dark
                        ? c.input
                        : c.input.withValues(alpha: 0),
                  ),
                  boxShadow: [
                    if (state.focused) ...t.focusRing,
                    if (active) ...t.shadowSm,
                  ],
                ),
                child: DefaultTextStyle.merge(
                  style: t.text.sm.copyWith(
                    color: fg,
                    fontWeight: FontWeight.w500,
                  ),
                  maxLines: 1,
                  child: IconTheme.merge(
                    data: IconThemeData(color: fg, size: 16),
                    child: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        if (tab.icon != null) ...[
                          tab.icon!,
                          const SizedBox(width: 6),
                        ],
                        Flexible(child: tab.label),
                      ],
                    ),
                  ),
                ),
              ),
            );
          },
        ),
    ];
    final across = widget.axis == Axis.horizontal,
        line = widget.variant == TabsVariant.line;
    Widget bar(Widget child) => Semantics(
      role: SemanticsRole.tabBar,
      container: true,
      explicitChildNodes: true,
      child: child,
    );
    final list = Shortcuts(
      includeSemantics: false,
      shortcuts: widget.axis == Axis.horizontal
          ? const {
              SingleActivator(LogicalKeyboardKey.arrowRight): _MoveIntent(1),
              SingleActivator(LogicalKeyboardKey.arrowLeft): _MoveIntent(-1),
            }
          : const {
              SingleActivator(LogicalKeyboardKey.arrowDown): _MoveIntent(1),
              SingleActivator(LogicalKeyboardKey.arrowUp): _MoveIntent(-1),
            },
      child: Actions(
        actions: {
          _MoveIntent: CallbackAction<_MoveIntent>(
            onInvoke: (i) => _move(i.step),
          ),
        },
        child: Container(
          constraints: across ? BoxConstraints(minHeight: t.control(36)) : null,
          padding: line ? EdgeInsets.zero : const EdgeInsets.all(3),
          decoration: line
              ? BoxDecoration(
                  border: across
                      ? Border(bottom: BorderSide(color: c.border))
                      : BorderDirectional(start: BorderSide(color: c.border)),
                )
              : BoxDecoration(
                  color: c.muted,
                  borderRadius: BorderRadius.circular(t.radiusLg),
                ),
          // Tabs wider than the room scroll sideways, on a phone say; the tab bar sits
          // inside the scrolling, so its tabs stay its own children.
          child: across && !widget.expand
              ? SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: bar(
                    Row(mainAxisSize: MainAxisSize.min, children: triggers),
                  ),
                )
              : bar(
                  across
                      ? Row(
                          children: [
                            for (final trigger in triggers)
                              Expanded(child: trigger),
                          ],
                        )
                      : IntrinsicWidth(
                          child: Column(
                            mainAxisSize: MainAxisSize.min,
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: [
                              for (final (i, trigger) in triggers.indexed) ...[
                                if (i > 0 && !line) const SizedBox(height: 2),
                                trigger,
                              ],
                            ],
                          ),
                        ),
                ),
        ),
      ),
    );
    final panel = Semantics(
      role: SemanticsRole.tabPanel,
      container: true,
      child: KeyedSubtree(
        key: ValueKey(_index),
        child: widget.tabs[_index].content,
      ),
    );
    if (!across) {
      return Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          list,
          const SizedBox(width: 16),
          Expanded(child: panel),
        ],
      );
    }
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: widget.expand
          ? CrossAxisAlignment.stretch
          : CrossAxisAlignment.start,
      children: [list, const SizedBox(height: 8), panel],
    );
  }
}

class _MoveIntent extends Intent {
  const _MoveIntent(this.step);

  final int step;
}