Ashlar UI Ashlar UI ashlarui

Shimmer

Text with a light passing over it, for what is still being worked on.

Shimmer, as it looks in an app Shimmer, as it looks in an app, dark
// The shimmer demo: the page's picture and what runs live.
import 'package:flutter/widgets.dart';

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

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Shimmer(child: Text('Thinking…', style: t.text.base)),
        const SizedBox(height: 12),
        Shimmer(child: Text('Searching this week\'s orders…', style: t.text.sm)),
      ],
    );
  }
}

Try its options

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

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

Add it

In your Flutter project:

$ ashlar add shimmer

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

API

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

Shimmerclass

Text, or any shape, with a light passing across it: for what is still being worked on, as "Thinking…" or "Searching the orders…". Drawn in the muted colour with the foreground passing over it; when the person asks for less motion it stands still, muted.

Parameter Type Default What it is for
childrequired Widget
enabled bool true False draws child as it is, with no light.
period Duration const Duration(milliseconds: 1800) How long the light takes to cross.

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

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// Text, or any shape, with a light passing across it: for what is still being worked on,
/// as "Thinking…" or "Searching the orders…". Drawn in the muted colour with the foreground
/// passing over it; when the person asks for less motion it stands still, muted.
///
/// ```dart
/// Shimmer(child: Text('Thinking…'))
/// ```
class Shimmer extends StatefulWidget {
  const Shimmer({
    super.key,
    required this.child,
    this.enabled = true,
    this.period = const Duration(milliseconds: 1800),
  });

  final Widget child;

  /// False draws [child] as it is, with no light.
  final bool enabled;

  /// How long the light takes to cross.
  final Duration period;

  @override
  State<Shimmer> createState() => _ShimmerState();
}

class _ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
  late final _pass = AnimationController(vsync: this, duration: widget.period);

  bool get _still => MediaQuery.maybeDisableAnimationsOf(context) ?? false;

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

  @override
  void didUpdateWidget(Shimmer old) {
    super.didUpdateWidget(old);
    if (old.period != widget.period) _pass.duration = widget.period;
    _sync();
  }

  void _sync() {
    if (widget.enabled && !_still) {
      if (!_pass.isAnimating) _pass.repeat();
    } else {
      _pass.stop();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    if (!widget.enabled) return widget.child;
    final c = AshlarTheme.of(context).colors;
    final base = c.mutedForeground, light = c.foreground;
    if (_still) {
      return DefaultTextStyle.merge(
        style: TextStyle(color: base),
        child: widget.child,
      );
    }
    final rtl = Directionality.of(context) == TextDirection.rtl;
    return AnimatedBuilder(
      animation: _pass,
      child: widget.child,
      builder: (context, child) {
        // The light starts off one side and ends off the other, reading the way the text does.
        final at = -2.0 + 4 * _pass.value;
        final x = rtl ? -at : at;
        return ShaderMask(
          blendMode: BlendMode.srcIn,
          shaderCallback: (bounds) => LinearGradient(
            begin: Alignment(x - 1, 0),
            end: Alignment(x + 1, 0),
            colors: [base, light, base],
            stops: const [.3, .5, .7],
          ).createShader(bounds),
          child: child,
        );
      },
    );
  }
}