ashlarui

Bottom Bar

An app's main places along the bottom of a phone screen, an icon and a name each.

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

import 'ui/bottom_bar.dart';
import 'ui/theme.dart';

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

  @override
  State<BottomBarDemo> createState() => _BottomBarDemoState();
}

class _BottomBarDemoState extends State<BottomBarDemo> {
  var page = 0;
  static const pages = ['Home', 'Search', 'Inbox', 'Profile'];

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    // A phone's screen, the bar along its foot.
    return Container(
      width: 360,
      height: 440,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        border: Border.all(color: t.colors.border),
        borderRadius: BorderRadius.circular(24),
      ),
      child: Column(
        children: [
          Expanded(
            child: Center(child: Text(pages[page], style: t.text.xl)),
          ),
          BottomBar(
            index: page,
            onChanged: (i) => setState(() => page = i),
            items: const [
              BottomBarItem(label: 'Home', icon: AshlarIcon(AshlarIconsRoundedStroke.house)),
              BottomBarItem(label: 'Search', icon: AshlarIcon(AshlarIconsRoundedStroke.search)),
              BottomBarItem(label: 'Inbox', icon: AshlarIcon(AshlarIconsRoundedStroke.inbox), badge: '3'),
              BottomBarItem(label: 'Profile', icon: AshlarIcon(AshlarIconsRoundedStroke.circleUser)),
            ],
          ),
        ],
      ),
    );
  }
}

Try its options

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

Bottom Bar with controls, as it looks in an app Bottom Bar with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add bottom-bar

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

API

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

BottomBarItemclass

One place in a BottomBar.

Parameter Type Default What it is for
labelrequired String Its name, under the icon.
iconrequired Widget
activeIcon Widget? Shown instead of icon while it is the place you are: a filled icon, say.
badge String? A count to show on the icon: unread messages. Null shows none.

BottomBarclass

An app's main places along the bottom of a phone screen, an icon and a name each, above the home indicator. Two to five places; the one you are in stands out.

To a screen reader it is a tab bar; from a keyboard, the arrow keys move along it.

Parameter Type Default What it is for
itemsrequired List<BottomBarItem>
indexrequired int The place you are in.
onChangedrequired ValueChanged<int> Told the place chosen.

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/bottom_bar.dart 193 lines
// Ashlar UI: Bottom Bar. Copied into your app by `ashlar add bottom-bar`; 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 place in a [BottomBar].
@immutable
class BottomBarItem {
  const BottomBarItem({
    required this.label,
    required this.icon,
    this.activeIcon,
    this.badge,
  });

  /// Its name, under the icon.
  final String label;
  final Widget icon;

  /// Shown instead of [icon] while it is the place you are: a filled icon, say.
  final Widget? activeIcon;

  /// A count to show on the icon: unread messages. Null shows none.
  final String? badge;
}

/// An app's main places along the bottom of a phone screen, an icon and a name each,
/// above the home indicator. Two to five places; the one you are in stands out.
///
/// To a screen reader it is a tab bar; from a keyboard, the arrow keys move along it.
///
/// ```dart
/// BottomBar(
///   index: page,
///   onChanged: (i) => setState(() => page = i),
///   items: [
///     BottomBarItem(label: 'Home', icon: AshlarIcon(AshlarIconsRoundedStroke.house)),
///     BottomBarItem(label: 'Inbox', icon: AshlarIcon(AshlarIconsRoundedStroke.inbox), badge: '3'),
///   ],
/// )
/// ```
class BottomBar extends StatelessWidget {
  const BottomBar({
    super.key,
    required this.items,
    required this.index,
    required this.onChanged,
  }) : assert(items.length >= 2 && items.length <= 5);

  final List<BottomBarItem> items;

  /// The place you are in.
  final int index;

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final rtl = isRtl(context);
    void step(int by) => onChanged((index + (rtl ? -by : by)) % items.length);
    return DecoratedBox(
      decoration: BoxDecoration(
        color: c.background,
        border: Border(top: BorderSide(color: c.border)),
      ),
      child: SafeArea(
        top: false,
        child: CallbackShortcuts(
          bindings: {
            const SingleActivator(LogicalKeyboardKey.arrowRight): () => step(1),
            const SingleActivator(LogicalKeyboardKey.arrowLeft): () => step(-1),
          },
          child: Semantics(
            role: SemanticsRole.tabBar,
            container: true,
            explicitChildNodes: true,
            child: SizedBox(
              height: 56,
              child: Row(
                children: [
                  for (final (i, item) in items.indexed)
                    Expanded(
                      child: Pressable(
                        role: SemanticsRole.tab,
                        selected: i == index,
                        semanticLabel: item.badge == null
                            ? item.label
                            : '${item.label}, ${item.badge}',
                        onPressed: () => onChanged(i),
                        builder: (context, state) {
                          final on = i == index;
                          final ink = on ? c.foreground : c.mutedForeground;
                          return AnimatedContainer(
                            duration: t.duration,
                            margin: const EdgeInsets.all(4),
                            decoration: BoxDecoration(
                              color: state.hovered
                                  ? c.muted
                                  : c.muted.withValues(alpha: 0),
                              borderRadius: BorderRadius.circular(t.radiusMd),
                              border: state.focused
                                  ? Border.all(color: c.ring, width: 2)
                                  : null,
                            ),
                            child: Column(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: [
                                Stack(
                                  clipBehavior: Clip.none,
                                  children: [
                                    IconTheme.merge(
                                      data: IconThemeData(color: ink, size: 22),
                                      child: on
                                          ? item.activeIcon ?? item.icon
                                          : item.icon,
                                    ),
                                    if (item.badge != null)
                                      PositionedDirectional(
                                        top: -4,
                                        end: -10,
                                        child: Container(
                                          constraints: const BoxConstraints(
                                            minWidth: 16,
                                          ),
                                          // As tall as it is drawn, or taller when larger text needs it.
                                          height: grownHeight(
                                            context,
                                            16,
                                            line: 10,
                                          ),
                                          padding: const EdgeInsets.symmetric(
                                            horizontal: 4,
                                          ),
                                          alignment: Alignment.center,
                                          decoration: BoxDecoration(
                                            color: c.destructive,
                                            borderRadius: BorderRadius.circular(
                                              8,
                                            ),
                                            border: Border.all(
                                              color: c.background,
                                              width: 1.5,
                                            ),
                                          ),
                                          child: Text(
                                            item.badge!,
                                            style: t.text.xs.copyWith(
                                              fontSize: 10,
                                              height: 1,
                                              color: c.destructiveForeground,
                                              fontWeight: FontWeight.w600,
                                            ),
                                          ),
                                        ),
                                      ),
                                  ],
                                ),
                                const SizedBox(height: 4),
                                Text(
                                  item.label,
                                  maxLines: 1,
                                  overflow: TextOverflow.ellipsis,
                                  style: t.text.xs.copyWith(
                                    color: ink,
                                    fontWeight: on
                                        ? FontWeight.w600
                                        : FontWeight.w500,
                                  ),
                                ),
                              ],
                            ),
                          );
                        },
                      ),
                    ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}