Fliin
On this page

Components / forms

Stepper

Sets a small count one step at a time, with round minus and plus buttons around the number at the end of a row: guests, quantity, seats. Each button turns off at its limit.

16 of 16 variants pass
Stepper, value state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add stepperAlso installs: core, icons

Examples

Value

A stepper at the end of a list row: minus and plus around the count, for small whole numbers.

Stepper, value state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperValueExample> createState() => _StepperValueExampleState();
}

class _StepperValueExampleState extends State<StepperValueExample> {
  var _adults = 2;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Adults',
          subtitle: 'Ages 13 and over',
          trailing: FlStepper(
            label: 'Adults',
            value: _adults,
            min: 1,
            max: 6,
            decrementLabel: 'Fewer adults',
            incrementLabel: 'More adults',
            format: (n) => '$n',
            onChanged: (value) => setState(() => _adults = value),
          ),
        ),
      ],
    );
  }
}

At min

At min, the minus button turns off.

Stepper, at-min state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperAtMinExample> createState() => _StepperAtMinExampleState();
}

class _StepperAtMinExampleState extends State<StepperAtMinExample> {
  var _adults = 0;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Adults',
          subtitle: 'Ages 13 and over',
          trailing: FlStepper(
            label: 'Adults',
            value: _adults,
            min: 0,
            max: 4,
            decrementLabel: 'Fewer adults',
            incrementLabel: 'More adults',
            format: (n) => '$n',
            onChanged: (value) => setState(() => _adults = value),
          ),
        ),
      ],
    );
  }
}

At max

At max, the plus button turns off.

Stepper, at-max state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperAtMaxExample> createState() => _StepperAtMaxExampleState();
}

class _StepperAtMaxExampleState extends State<StepperAtMaxExample> {
  var _adults = 2;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Adults',
          subtitle: 'Ages 13 and over',
          trailing: FlStepper(
            label: 'Adults',
            value: _adults,
            min: 0,
            max: 2,
            decrementLabel: 'Fewer adults',
            incrementLabel: 'More adults',
            format: (n) => '$n',
            onChanged: (value) => setState(() => _adults = value),
          ),
        ),
      ],
    );
  }
}

Disabled

onChanged: null disables the stepper, and enabled: false the row around it.

Stepper, disabled state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperDisabledExample> createState() => _StepperDisabledExampleState();
}

class _StepperDisabledExampleState extends State<StepperDisabledExample> {
  final _adults = 1;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Adults',
          subtitle: 'Ages 13 and over',
          enabled: false,
          trailing: FlStepper(
            label: 'Adults',
            value: _adults,
            min: 0,
            max: 2,
            decrementLabel: 'Fewer adults',
            incrementLabel: 'More adults',
            format: (n) => '$n',
            onChanged: null,
          ),
        ),
      ],
    );
  }
}

Outline

FlStepperVariant.outline: round buttons ringed by a rule, the guest picker of a booking app.

Stepper, outline state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperOutlineExample> createState() => _StepperOutlineExampleState();
}

class _StepperOutlineExampleState extends State<StepperOutlineExample> {
  var _adults = 2;
  var _children = 0;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Adults',
          subtitle: 'Ages 13 and over',
          trailing: FlStepper(
            label: 'Adults',
            value: _adults,
            min: 1,
            max: 16,
            variant: FlStepperVariant.outline,
            decrementLabel: 'Fewer adults',
            incrementLabel: 'More adults',
            onChanged: (value) => setState(() => _adults = value),
          ),
        ),
        FlListRow(
          title: 'Children',
          subtitle: 'Ages 2 to 12',
          trailing: FlStepper(
            label: 'Children',
            value: _children,
            max: 8,
            variant: FlStepperVariant.outline,
            decrementLabel: 'Fewer children',
            incrementLabel: 'More children',
            onChanged: (value) => setState(() => _children = value),
          ),
        ),
      ],
    );
  }
}

Step

step counts by more than one, here by quarter hours; near a bound the number stops at the bound.

Stepper, step state
import 'package:your_app/ui/fliin/components/list_row/fl_list_row.dart';
import 'package:your_app/ui/fliin/components/stepper/fl_stepper.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<StepperStepExample> createState() => _StepperStepExampleState();
}

class _StepperStepExampleState extends State<StepperStepExample> {
  var _minutes = 30;

  @override
  Widget build(BuildContext context) {
    return FlListGroup(
      children: [
        FlListRow(
          title: 'Duration',
          trailing: FlStepper(
            label: 'Duration',
            value: _minutes,
            min: 15,
            max: 180,
            step: 15,
            format: (minutes) => '$minutes min',
            decrementLabel: 'Shorter',
            incrementLabel: 'Longer',
            onChanged: (value) => setState(() => _minutes = value),
          ),
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: SingleChildScrollView(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        FlListGroup(
          children: [
            FlListRow(
              title: (variant.dir == TextDirection.rtl ? "البالغون" : "Adults"),
              subtitle: (variant.dir == TextDirection.rtl
                  ? "١٣ سنة فأكثر"
                  : "13 and older"),
              enabled: true,
              trailing: FlStepper(
                label: (variant.dir == TextDirection.rtl
                    ? "البالغون"
                    : "Adults"),
                value: 2,
                min: 1,
                max: 6,
                decrementLabel: (variant.dir == TextDirection.rtl
                    ? "أقل"
                    : "Fewer"),
                incrementLabel: (variant.dir == TextDirection.rtl
                    ? "أكثر"
                    : "Plus"),
                format: (int n) => variant.dir == TextDirection.rtl
                    ? "$n".replaceAllMapped(
                        RegExp("[0-9]"),
                        (m) => "٠١٢٣٤٥٦٧٨٩"[int.parse(m[0]!)],
                      )
                    : "$n",
                onChanged: (_) {},
              ),
            ),
            FlListRow(
              title: (variant.dir == TextDirection.rtl
                  ? "الأطفال"
                  : "Children"),
              subtitle: (variant.dir == TextDirection.rtl
                  ? "من ٢ إلى ١٢ سنة"
                  : "Ages 2 to 12"),
              enabled: true,
              trailing: FlStepper(
                label: (variant.dir == TextDirection.rtl
                    ? "الأطفال"
                    : "Children"),
                value: 0,
                min: 0,
                max: 4,
                decrementLabel: (variant.dir == TextDirection.rtl
                    ? "أقل"
                    : "Fewer"),
                incrementLabel: (variant.dir == TextDirection.rtl
                    ? "أكثر"
                    : "Plus"),
                format: (int n) => variant.dir == TextDirection.rtl
                    ? "$n".replaceAllMapped(
                        RegExp("[0-9]"),
                        (m) => "٠١٢٣٤٥٦٧٨٩"[int.parse(m[0]!)],
                      )
                    : "$n",
                onChanged: (_) {},
              ),
            ),
            FlListRow(
              title: (variant.dir == TextDirection.rtl ? "الرضّع" : "Infants"),
              subtitle: (variant.dir == TextDirection.rtl
                  ? "أقل من سنتين"
                  : "Under 2"),
              enabled: true,
              trailing: FlStepper(
                label: (variant.dir == TextDirection.rtl
                    ? "الرضّع"
                    : "Infants"),
                value: 2,
                min: 0,
                max: 2,
                decrementLabel: (variant.dir == TextDirection.rtl
                    ? "أقل"
                    : "Fewer"),
                incrementLabel: (variant.dir == TextDirection.rtl
                    ? "أكثر"
                    : "Plus"),
                format: (int n) => variant.dir == TextDirection.rtl
                    ? "$n".replaceAllMapped(
                        RegExp("[0-9]"),
                        (m) => "٠١٢٣٤٥٦٧٨٩"[int.parse(m[0]!)],
                      )
                    : "$n",
                onChanged: (_) {},
              ),
            ),
            FlListRow(
              title: (variant.dir == TextDirection.rtl ? "الحيوانات" : "Pets"),
              enabled: false,
              trailing: FlStepper(
                label: (variant.dir == TextDirection.rtl
                    ? "الحيوانات"
                    : "Pets"),
                value: 1,
                min: 0,
                max: 2,
                decrementLabel: (variant.dir == TextDirection.rtl
                    ? "أقل"
                    : "Fewer"),
                incrementLabel: (variant.dir == TextDirection.rtl
                    ? "أكثر"
                    : "Plus"),
                format: (int n) => variant.dir == TextDirection.rtl
                    ? "$n".replaceAllMapped(
                        RegExp("[0-9]"),
                        (m) => "٠١٢٣٤٥٦٧٨٩"[int.parse(m[0]!)],
                      )
                    : "$n",
                onChanged: null,
              ),
            ),
          ],
        ),
      ],
    ),
  ),
);

API

FlStepperVariant: enum {control, outline}
FlStepperVariant.control: static final FlStepperVariant
FlStepperVariant.outline: static final FlStepperVariant
FlStepper: class extends StatelessWidget
FlStepper.new: const new({super.key, required int value, required ValueChanged<int>? onChanged, required String label, required String decrementLabel, required String incrementLabel, int min, int? max, String Function(int value)? format, int step, FlStepperVariant variant})
FlStepper.value: get int
FlStepper.onChanged: get ValueChanged<int>?
FlStepper.label: get String
FlStepper.decrementLabel: get String
FlStepper.incrementLabel: get String
FlStepper.min: get int
FlStepper.max: get int?
FlStepper.format: get String Function(int value)?
FlStepper.step: get int
FlStepper.variant: get FlStepperVariant
FlStepper.build: Widget(BuildContext context)

Reference renders

The 16 images published with version 0.1.4, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify stepper compares your copy to them.

iOS · Light
Stepper, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Stepper, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Stepper, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Stepper, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Stepper, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Stepper, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Stepper, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Stepper, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Stepper, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Stepper, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Stepper, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Stepper, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Stepper, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Stepper, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Stepper, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Stepper, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • pick how many adults and children are in a booking
  • adjust how many tickets or seats with a counter
  • use a small counter at the end of a list row
  • add guests one at a time
  • disable a counter button at its limit
Not for
  • a number typed on the keyboard, a price, a weight: use the number field (number-field)
  • a value on a continuous range: use the slider (slider)
  • a quantity in a cart line: use the cart line (cart-line)
States
  • Value
  • At min
  • At max
  • Disabled

Source

The files fliin add stepper copies into your app, before your prefix and imports are applied.

stepper/fl_stepper.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';
import 'package:fliin_ui/icons/fl_icons.dart';

/// The look of an [FlStepper]'s buttons.
enum FlStepperVariant {
  /// `control` squares with `shape.radiusXl` corners: the default.
  control,

  /// Circles outlined by a `border` rule on no fill, which take
  /// `controlPressed` under the finger: the guest counter of a booking app.
  outline,
}

/// A compact counter: minus, the number, plus. The buttons are small
/// `control` squares with `shape.radiusXl` corners (outlined circles with
/// [variant]), as wide as a close button (`size.closeButton`), inside the
/// platform's touch target; each one disables itself at its bound, at
/// `size.disabledOpacity`. To
/// be placed at the end of a row ("Adults", "Quantity") rather than alone in
/// a form, where `number-field` also lets the user type the number.
///
/// The screen reader reads the counter as one adjustable node, named
/// [label], which speaks the number and steps up or down through the
/// platform's gestures; both buttons stay reachable, by touch as by
/// keyboard, named [decrementLabel] and [incrementLabel].
class FlStepper extends StatelessWidget {
  /// A counter showing [value], between [min] and [max].
  const FlStepper({
    super.key,
    required this.value,
    required this.onChanged,
    required this.label,
    required this.decrementLabel,
    required this.incrementLabel,
    this.min = 0,
    this.max,
    this.format,
    this.step = 1,
    this.variant = FlStepperVariant.control,
  }) : assert(max == null || max >= min, 'max at least min'),
       assert(step > 0, 'a positive step');

  /// The number shown, between [min] and [max].
  final int value;

  /// Receives the number one step higher or lower; when null, the counter is
  /// disabled.
  final ValueChanged<int>? onChanged;

  /// What the counter counts, for the screen reader ("Adults").
  final String label;

  /// The name of the minus button ("Fewer adults").
  final String decrementLabel;

  /// The name of the plus button ("More adults").
  final String incrementLabel;

  /// The smallest number; 0 by default.
  final int min;

  /// The largest number; when null, unbounded.
  final int? max;

  /// The number written in the app's language ("٢" in Arabic); when null, in
  /// Latin digits.
  final String Function(int value)? format;

  /// What a button adds or removes; 1 by default. Near a bound, the number
  /// stops at the bound.
  final int step;

  /// The buttons' look; `control` squares by default.
  final FlStepperVariant variant;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final onChanged = this.onChanged;
    final max = this.max;
    String text(int n) => format?.call(n) ?? '$n';
    final canDecrease = onChanged != null && value > min;
    final canIncrease = onChanged != null && (max == null || value < max);
    final increased = max == null ? value + step : math.min(value + step, max);
    final decreased = math.max(value - step, min);
    final colors = theme.colors;
    final outline = variant == FlStepperVariant.outline;
    final side = theme.size.closeButton;
    final radius = outline ? side / 2 : theme.shape.radiusXl;
    Widget button(String label, FlIconData icon, int next, {required bool enabled}) => FlPressable(
      label: label,
      onPressed: enabled ? () => onChanged!(next) : null,
      borderRadius: BorderRadius.circular(radius),
      builder: (context, states) => Opacity(
        opacity: states.contains(WidgetState.disabled) ? theme.size.disabledOpacity : 1,
        child: SizedBox.square(
          dimension: side,
          child: DecoratedBox(
            decoration: ShapeDecoration(
              color: states.contains(WidgetState.pressed) || states.contains(WidgetState.hovered)
                  ? colors.controlPressed
                  : (outline ? colors.controlPressed.withValues(alpha: 0) : colors.control),
              shape: outline
                  ? CircleBorder(
                      side: BorderSide(color: colors.border, width: theme.size.border),
                    )
                  : FlContinuousBorder(radius: radius),
            ),
            child: Center(
              child: FlIcon(icon, size: theme.size.indicatorIcon, color: colors.onControl),
            ),
          ),
        ),
      ),
    );
    final style = theme.typography
        .resolve(FlTextRole.textBase)
        .copyWith(color: colors.onSurface, fontFeatures: const [FontFeature.tabularFigures()]);
    return Semantics(
      container: true,
      explicitChildNodes: true,
      label: label,
      value: text(value),
      increasedValue: canIncrease ? text(increased) : null,
      decreasedValue: canDecrease ? text(decreased) : null,
      onIncrease: canIncrease ? () => onChanged(increased) : null,
      onDecrease: canDecrease ? () => onChanged(decreased) : null,
      enabled: onChanged != null,
      child: Row(
        mainAxisSize: MainAxisSize.min,
        spacing: theme.space.md,
        children: [
          button(decrementLabel, FlIcons.minus, decreased, enabled: canDecrease),
          // As wide as its digits, as in the canvas: from 9 to 10, the minus
          // button moves away.
          ExcludeSemantics(
            child: _Rolling(
              value: value,
              child: Text(
                text(value),
                key: ValueKey(value),
                textAlign: TextAlign.center,
                style: onChanged == null
                    ? style.copyWith(color: style.color?.withValues(alpha: theme.size.disabledOpacity))
                    : style,
                strutStyle: theme.typography.strut(
                  FlTextRole.textBase,
                  inheritedFamily: DefaultTextStyle.of(context).style.fontFamily,
                ),
              ),
            ),
          ),
          button(incrementLabel, FlIcons.plus, increased, enabled: canIncrease),
        ],
      ),
    );
  }
}

/// The number rolls to its new value: the old one slides out and fades
/// away from the direction of the change, the new one slides in from it,
/// over `short4`. Under reduced motion, the number is replaced at once.
class _Rolling extends StatefulWidget {
  const _Rolling({required this.value, required this.child});

  final int value;
  final Widget child;

  @override
  State<_Rolling> createState() => _RollingState();
}

class _RollingState extends State<_Rolling> {
  /// 1 when the value went up: the new number comes from below.
  double _direction = 1;

  @override
  void didUpdateWidget(_Rolling oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.value != oldWidget.value) {
      _direction = widget.value > oldWidget.value ? 1 : -1;
    }
  }

  @override
  Widget build(BuildContext context) {
    final motion = FlMotion.of(context);
    return ClipRect(
      child: AnimatedSwitcher(
        duration: motion.reduced ? Duration.zero : motion.tokens.short4,
        switchInCurve: motion.tokens.easeOut,
        switchOutCurve: motion.tokens.easeOut,
        layoutBuilder: (current, previous) => Stack(alignment: Alignment.center, children: [...previous, ?current]),
        transitionBuilder: (child, animation) {
          final entering = child.key == ValueKey(widget.value);
          // The leaving number runs its animation backward, from 1 to 0.
          final offset = Tween<Offset>(
            begin: Offset(0, (entering ? 1 : -1) * _direction * 0.5),
            end: Offset.zero,
          ).animate(animation);
          return FadeTransition(
            opacity: animation,
            child: SlideTransition(position: offset, child: child),
          );
        },
        child: widget.child,
      ),
    );
  }
}

Arrows to move, Enter to open, Escape to close.