ashlarui

Swipe Actions

A row with actions behind it: swipe it aside to show them, all the way to do the first.

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

import 'ui/item.dart';
import 'ui/separator.dart';
import 'ui/swipe_actions.dart';
import 'ui/theme.dart';

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

  @override
  State<SwipeActionsDemo> createState() => _SwipeActionsDemoState();
}

class _SwipeActionsDemoState extends State<SwipeActionsDemo> {
  var mail = ['Achieng Otieno', 'Kofi Mensah', 'Amina Diallo', 'Tendai Moyo'];
  final pinned = <String>{};

  @override
  Widget build(BuildContext context) => Container(
    width: 360,
    clipBehavior: Clip.antiAlias,
    decoration: BoxDecoration(
      border: Border.all(color: AshlarTheme.of(context).colors.border),
      borderRadius: BorderRadius.circular(12),
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (final (i, from) in mail.indexed) ...[
          if (i > 0) const Separator(),
          SwipeActions(
            key: ValueKey(from),
            start: [
              SwipeAction(
                label: pinned.contains(from) ? 'Unpin' : 'Pin',
                icon: const AshlarIcon(AshlarIconsRoundedStroke.pin),
                onPressed: () => setState(
                  () => pinned.contains(from) ? pinned.remove(from) : pinned.add(from),
                ),
              ),
            ],
            end: [
              SwipeAction(
                label: 'Delete',
                icon: const AshlarIcon(AshlarIconsRoundedStroke.trash),
                destructive: true,
                onPressed: () => setState(() => mail = [...mail]..remove(from)),
              ),
            ],
            child: Item(
              title: Text(from),
              description: const Text('Swipe either way.'),
              trailing: pinned.contains(from) ? const AshlarIcon(AshlarIconsRoundedStroke.pin, size: 14) : null,
            ),
          ),
        ],
      ],
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add swipe-actions

It writes theme.dart, palette.dart, group.dart, pressable.dart and swipe_actions.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.

SwipeActionclass

One action behind a row: archive, delete, pin.

Parameter Type Default What it is for
labelrequired String What it does, under its icon and to a screen reader.
iconrequired Widget
onPressedrequired VoidCallback
destructive bool false Drawn in the destructive colour, for what cannot be undone.

SwipeActionsclass

A row with actions behind it, as phone lists have: swipe it aside to show them, then press one. Swiped all the way across, it does the first action on that side.

A screen reader is offered the actions directly, without swiping. Where there is a pointer and a keyboard, put the same actions in a menu on the row as well.

Parameter Type Default What it is for
childrequired Widget The row.
start List<SwipeAction> const [] Behind the row's start, shown by swiping towards the end.
end List<SwipeAction> const [] Behind the row's end, shown by swiping towards the start.
fullSwipe bool true Swiped all the way across, it does the first action on that side.
actionWidth double 72 Each action's width.

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/swipe_actions.dart 241 lines
// Ashlar UI: Swipe Actions. Copied into your app by `ashlar add swipe-actions`; it is
// yours to change.

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

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

/// One action behind a row: archive, delete, pin.
@immutable
class SwipeAction {
  const SwipeAction({
    required this.label,
    required this.icon,
    required this.onPressed,
    this.destructive = false,
  });

  /// What it does, under its icon and to a screen reader.
  final String label;
  final Widget icon;
  final VoidCallback onPressed;

  /// Drawn in the destructive colour, for what cannot be undone.
  final bool destructive;
}

/// A row with actions behind it, as phone lists have: swipe it aside to show them, then
/// press one. Swiped all the way across, it does the first action on that side.
///
/// A screen reader is offered the actions directly, without swiping. Where there is a
/// pointer and a keyboard, put the same actions in a menu on the row as well.
///
/// ```dart
/// SwipeActions(
///   end: [SwipeAction(label: 'Delete', icon: AshlarIcon(AshlarIconsRoundedStroke.trash), destructive: true, onPressed: delete)],
///   child: Item(title: Text('Invoice 2041')),
/// )
/// ```
class SwipeActions extends StatefulWidget {
  const SwipeActions({
    super.key,
    required this.child,
    this.start = const [],
    this.end = const [],
    this.fullSwipe = true,
    this.actionWidth = 72,
  });

  /// The row.
  final Widget child;

  /// Behind the row's start, shown by swiping towards the end.
  final List<SwipeAction> start;

  /// Behind the row's end, shown by swiping towards the start.
  final List<SwipeAction> end;

  /// Swiped all the way across, it does the first action on that side.
  final bool fullSwipe;

  /// Each action's width.
  final double actionWidth;

  @override
  State<SwipeActions> createState() => _SwipeActionsState();
}

class _SwipeActionsState extends State<SwipeActions>
    with SingleTickerProviderStateMixin {
  // How far the row has moved, in logical pixels: towards the end is positive.
  late final AnimationController _offset =
      AnimationController.unbounded(vsync: this)..addListener(() {
        final open = _offset.value != 0;
        if (open != _open) setState(() => _open = open);
      });
  double _width = 0;

  /// Moved aside: a tap then closes it, rather than reaching the row.
  var _open = false;

  double get _startRoom => widget.start.length * widget.actionWidth;
  double get _endRoom => widget.end.length * widget.actionWidth;

  @override
  void dispose() {
    _offset.dispose();
    super.dispose();
  }

  void _settle(double to) => _offset.animateTo(
    to,
    duration: const Duration(milliseconds: 220),
    curve: Curves.easeOutCubic,
  );

  void close() => _settle(0);

  void _drag(DragUpdateDetails d) {
    final rtl = isRtl(context);
    final by = rtl ? -d.primaryDelta! : d.primaryDelta!;
    var next = _offset.value + by;
    // Only as far as there are actions that way; past the actions only on a full swipe.
    final most = widget.fullSwipe && widget.start.isNotEmpty
        ? _width
        : _startRoom;
    final least = widget.fullSwipe && widget.end.isNotEmpty
        ? -_width
        : -_endRoom;
    next = next.clamp(least, most);
    _offset.value = next;
  }

  void _release(DragEndDetails d) {
    final v = _offset.value;
    final speed = (isRtl(context) ? -1 : 1) * (d.primaryVelocity ?? 0);
    // Across most of the row: the first action on that side.
    if (widget.fullSwipe && _width > 0 && v.abs() > _width * .6) {
      final action = v > 0 ? widget.start.first : widget.end.first;
      _settle(0);
      action.onPressed();
      return;
    }
    if (v > 0) {
      _settle(v > _startRoom / 2 || speed > 400 ? _startRoom : 0);
    } else if (v < 0) {
      _settle(-v > _endRoom / 2 || speed < -400 ? -_endRoom : 0);
    }
  }

  Widget _actions(List<SwipeAction> actions, bool atStart) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (final a in actions)
          Pressable(
            semanticLabel: a.label,
            onPressed: () {
              close();
              a.onPressed();
            },
            builder: (context, state) => Container(
              width: widget.actionWidth,
              color: a.destructive ? c.destructive : c.muted,
              alignment: Alignment.center,
              child: IconTheme.merge(
                data: IconThemeData(
                  color: a.destructive ? c.destructiveForeground : c.foreground,
                  size: 18,
                ),
                child: DefaultTextStyle.merge(
                  style: t.text.xs.copyWith(
                    color: a.destructive
                        ? c.destructiveForeground
                        : c.foreground,
                  ),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      a.icon,
                      const SizedBox(height: 4),
                      Text(a.label),
                    ],
                  ),
                ),
              ),
            ),
          ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    final c = AshlarTheme.of(context).colors;
    final rtl = isRtl(context);
    return Semantics(
      // Without swiping: each action as an action on the row.
      customSemanticsActions: {
        for (final a in [...widget.start, ...widget.end])
          CustomSemanticsAction(label: a.label): a.onPressed,
      },
      child: LayoutBuilder(
        builder: (context, box) {
          _width = box.maxWidth;
          return ClipRect(
            child: AnimatedBuilder(
              animation: _offset,
              builder: (context, child) {
                final v = _offset.value;
                return Stack(
                  children: [
                    // Behind the row: only the side being shown, so nothing peeks out.
                    if (v > 0)
                      Positioned.fill(
                        child: Align(
                          alignment: AlignmentDirectional.centerStart,
                          child: ExcludeSemantics(
                            excluding: v < _startRoom,
                            child: _actions(widget.start, true),
                          ),
                        ),
                      ),
                    if (v < 0)
                      Positioned.fill(
                        child: Align(
                          alignment: AlignmentDirectional.centerEnd,
                          child: ExcludeSemantics(
                            excluding: -v < _endRoom,
                            child: _actions(widget.end, false),
                          ),
                        ),
                      ),
                    Transform.translate(
                      offset: Offset(rtl ? -v : v, 0),
                      child: child,
                    ),
                  ],
                );
              },
              child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                onHorizontalDragUpdate: _drag,
                onHorizontalDragEnd: _release,
                // A tap on an open row closes it rather than reaching the row.
                onTap: _open ? close : null,
                child: IgnorePointer(
                  ignoring: _open,
                  child: ColoredBox(color: c.background, child: widget.child),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}