ashlarui

Progress

How far along something is, as a bar.

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

import 'ui/progress.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 320,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Progress(value: .66, semanticLabel: 'Upload'),
        SizedBox(height: 24),
        Progress(semanticLabel: 'Loading'),
      ],
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add progress

It writes theme.dart, palette.dart and progress.dart into lib/ui, keeping any you already have.

Examples

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

Not known yet

Moving, with no value.

Not known yet, as it looks in an app Not known yet, as it looks in an app, dark
// Not known yet: Moving, with no value.
import 'package:flutter/widgets.dart';

import 'ui/progress.dart';

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

  @override
  Widget build(BuildContext context) => const SizedBox(width: 300, child: Progress(semanticLabel: 'Loading'));
}

API

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

Progressclass

How far along something is, as a bar. A null value means not known yet: the bar then sweeps across, unless the device asks for less motion.

Parameter Type Default What it is for
value double? From 0 to 1, or null when it is not known.
semanticLabel String? What a screen reader calls it when its visible text does not say, as for an icon alone.

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

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// How far along something is, as a bar. A null [value] means not known yet: the bar
/// then sweeps across, unless the device asks for less motion.
///
/// ```dart
/// Progress(value: uploaded / total, semanticLabel: 'Upload')
/// ```
class Progress extends StatefulWidget {
  const Progress({super.key, this.value, this.semanticLabel});

  /// From 0 to 1, or null when it is not known.
  final double? value;

  /// What a screen reader calls it when its visible text does not say, as for an icon
  /// alone.
  final String? semanticLabel;

  @override
  State<Progress> createState() => _ProgressState();
}

class _ProgressState extends State<Progress>
    with SingleTickerProviderStateMixin {
  late final _sweep = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1500),
  );

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _update();
  }

  @override
  void didUpdateWidget(Progress old) {
    super.didUpdateWidget(old);
    _update();
  }

  void _update() {
    final still = MediaQuery.maybeDisableAnimationsOf(context) ?? false;
    if (widget.value == null && !still) {
      if (!_sweep.isAnimating) _sweep.repeat();
    } else {
      _sweep.stop();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final value = widget.value?.clamp(0.0, 1.0);
    final fill = DecoratedBox(
      decoration: BoxDecoration(
        color: c.primary,
        borderRadius: BorderRadius.circular(4),
      ),
    );
    return Semantics(
      label: widget.semanticLabel,
      value: value == null ? null : '${(value * 100).round()}%',
      child: Container(
        height: 8,
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: c.primary.withValues(alpha: c.primary.a * .2),
          borderRadius: BorderRadius.circular(4),
        ),
        child: value != null
            ? Align(
                alignment: AlignmentDirectional.centerStart,
                child: TweenAnimationBuilder<double>(
                  tween: Tween(end: value),
                  duration: t.duration * 2,
                  builder: (context, v, child) => FractionallySizedBox(
                    widthFactor: v,
                    heightFactor: 1,
                    child: child,
                  ),
                  child: fill,
                ),
              )
            : AnimatedBuilder(
                animation: _sweep,
                builder: (context, child) => FractionallySizedBox(
                  widthFactor: .4,
                  heightFactor: 1,
                  // From off the left edge to off the right.
                  alignment: Alignment(-2.4 + _sweep.value * 4.8, 0),
                  child: child,
                ),
                child: fill,
              ),
      ),
    );
  }
}