ashlarui

Pull to Refresh

A list that refreshes when pulled down from its top, with a Refresh action for screen readers.

Pull to Refresh, as it looks in an app Pull to Refresh, as it looks in an app, dark
import 'dart:ui' show PointerDeviceKind;

import 'package:flutter/widgets.dart';

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

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

  @override
  State<PullToRefreshDemo> createState() => _PullToRefreshDemoState();
}

class _PullToRefreshDemoState extends State<PullToRefreshDemo> {
  var refreshed = 0;

  Future<void> refresh() async {
    await Future<void>.delayed(const Duration(milliseconds: 1200));
    setState(() => refreshed++);
  }

  @override
  Widget build(BuildContext context) => Container(
    width: 340,
    height: 420,
    clipBehavior: Clip.antiAlias,
    decoration: BoxDecoration(
      border: Border.all(color: AshlarTheme.of(context).colors.border),
      borderRadius: BorderRadius.circular(12),
    ),
    // On this page a mouse may pull it too, so it can be tried without a touch screen.
    child: ScrollConfiguration(
      behavior: ScrollConfiguration.of(context).copyWith(
        dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad},
      ),
      child: PullToRefresh(
        onRefresh: refresh,
        child: ListView(
          children: [
            Padding(
              padding: const EdgeInsets.all(12),
              child: Text(
                refreshed == 0 ? 'Pull down to refresh.' : 'Refreshed $refreshed time${refreshed == 1 ? '' : 's'}.',
              ),
            ),
            for (var i = 0; i < 12; i++) ...[
              const Separator(),
              Item(title: Text('Order #${4000 + i}'), description: const Text('Ready for delivery')),
            ],
          ],
        ),
      ),
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add pull-to-refresh

It writes spinner.dart, theme.dart, palette.dart and pull_to_refresh.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs spinner and ashlar_icons, which come with it.

API

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

PullToRefreshclass

A list that refreshes when pulled down from its top, as phone lists do: a spinner shows how far to pull, then turns while onRefresh runs.

Works with both ways of scrolling: past the top with a bounce (iOS), or stopping at it (Android). A screen reader is offered Refresh as an action instead of pulling. A mouse does not pull a list, so on a desktop give a refresh button as well.

Parameter Type Default What it is for
onRefreshrequired Future<void> Function() Called once pulled far enough; the spinner turns until it completes.
childrequired Widget The scrolling list.
distance double 80 How far to pull before letting go refreshes.
semanticLabel String 'Refresh' What a screen reader calls the action.

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/pull_to_refresh.dart 220 lines
// Ashlar UI: Pull to Refresh. Copied into your app by `ashlar add pull-to-refresh`; it is
// yours to change.

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

import 'spinner.dart';
import 'theme.dart';

/// A list that refreshes when pulled down from its top, as phone lists do: a spinner
/// shows how far to pull, then turns while [onRefresh] runs.
///
/// Works with both ways of scrolling: past the top with a bounce (iOS), or stopping at it
/// (Android). A screen reader is offered Refresh as an action instead of pulling. A mouse
/// does not pull a list, so on a desktop give a refresh button as well.
///
/// ```dart
/// PullToRefresh(
///   onRefresh: () => orders.reload(),
///   child: ListView(children: [for (final o in orders) Item(title: Text(o.name))]),
/// )
/// ```
class PullToRefresh extends StatefulWidget {
  const PullToRefresh({
    super.key,
    required this.onRefresh,
    required this.child,
    this.distance = 80,
    this.semanticLabel = 'Refresh',
  });

  /// Called once pulled far enough; the spinner turns until it completes.
  final Future<void> Function() onRefresh;

  /// The scrolling list.
  final Widget child;

  /// How far to pull before letting go refreshes.
  final double distance;

  /// What a screen reader calls the action.
  final String semanticLabel;

  @override
  State<PullToRefresh> createState() => _PullToRefreshState();
}

class _PullToRefreshState extends State<PullToRefresh>
    with SingleTickerProviderStateMixin {
  // How far it has been pulled past the top.
  double _pulled = 0;
  bool _dragging = false, _working = false;

  // How far the list itself is past its top, when it bounces.
  double _over = 0;
  bool _bounced = false;

  /// Holding the list down under the spinner while it turns, as a bouncing list does.
  bool _holding = false;
  late final AnimationController _back =
      AnimationController(
          vsync: this,
          duration: const Duration(milliseconds: 200),
        )
        ..addListener(() => setState(() => _pulled = _from * (1 - _back.value)))
        ..addStatusListener((s) {
          if (s == AnimationStatus.completed) setState(() => _holding = false);
        });
  double _from = 0;

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

  Future<void> _refresh() async {
    if (_working) return;
    setState(() {
      _working = true;
      _holding = _bounced;
      _pulled = widget.distance;
    });
    try {
      await widget.onRefresh();
    } finally {
      if (mounted) {
        setState(() => _working = false);
        _settle();
      }
    }
  }

  void _settle() {
    _from = _pulled;
    _back.forward(from: 0);
  }

  /// The finger has lifted: refresh if pulled far enough, otherwise let go.
  void _lifted() {
    _dragging = false;
    if (_pulled >= widget.distance) {
      _refresh();
    } else if (_pulled > 0) {
      _settle();
    }
  }

  bool _onScroll(ScrollNotification n) {
    if (n.depth != 0) return false;
    if (n is ScrollUpdateNotification) {
      final over = (n.metrics.minScrollExtent - n.metrics.pixels).clamp(
        0.0,
        double.infinity,
      );
      if (over != _over) setState(() => _over = over);
    }
    if (_working) return false;
    if (n is ScrollStartNotification && n.dragDetails != null) {
      _dragging = true;
      _bounced = false;
    }
    if (n is ScrollUpdateNotification && _dragging) {
      if (n.dragDetails == null) {
        // Moving on its own: the finger lifted and a bounce or fling began.
        _lifted();
        return false;
      }
      // Bouncing: the list itself goes past its top.
      if (n.metrics.pixels < n.metrics.minScrollExtent) {
        setState(() {
          _bounced = true;
          _pulled = n.metrics.minScrollExtent - n.metrics.pixels;
        });
      } else if (_pulled > 0 && n.scrollDelta! > 0) {
        // Pushed back up while pulled: the mark goes back with it.
        setState(
          () => _pulled = (_pulled - n.scrollDelta!).clamp(0, double.infinity),
        );
      }
    }
    // Stopping at the top: the pull is what it would have scrolled.
    if (n is OverscrollNotification &&
        n.dragDetails != null &&
        n.overscroll < 0) {
      setState(
        () => _pulled = (_pulled - n.overscroll * .5).clamp(
          0,
          widget.distance * 1.5,
        ),
      );
    }
    if (n is ScrollEndNotification && _dragging) _lifted();
    return false;
  }

  @override
  Widget build(BuildContext context) {
    final c = AshlarTheme.of(context).colors;
    final share = (_pulled / widget.distance).clamp(0.0, 1.0);
    return Semantics(
      customSemanticsActions: {
        CustomSemanticsAction(label: widget.semanticLabel): _refresh,
      },
      child: NotificationListener<ScrollNotification>(
        onNotification: _onScroll,
        child: Stack(
          children: [
            // What the list has bounced back, made up so the spinner has room.
            Transform.translate(
              offset: Offset(
                0,
                _holding ? (_pulled - _over).clamp(0, widget.distance) : 0,
              ),
              child: widget.child,
            ),
            if (_pulled > 0 || _working)
              Positioned(
                top: (_pulled.clamp(0, widget.distance) - 40) / 2 + 8,
                left: 0,
                right: 0,
                child: IgnorePointer(
                  child: Center(
                    child: Opacity(
                      opacity: _working ? 1 : share,
                      child: Container(
                        width: 32,
                        height: 32,
                        alignment: Alignment.center,
                        decoration: BoxDecoration(
                          color: c.popover,
                          shape: BoxShape.circle,
                          border: Border.all(color: c.border),
                        ),
                        child: _working
                            ? const Spinner(
                                size: 16,
                                semanticLabel: 'Refreshing',
                              )
                            // How far to go, as a turn of the mark: a full turn refreshes.
                            : Transform.rotate(
                                angle: share * 6.28,
                                child: AshlarIcon(
                                  AshlarIconsRoundedStroke.loaderCircle,
                                  size: 16,
                                  color: c.foreground,
                                ),
                              ),
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}