Pull to refresh

The pull-to-refresh gesture is a popular UI mechanic that is used not only in Google’s Material Design, but also in Apple’s Human Interface Guidelines. No surprise this feature is also represented in the Flutter standard library. It’s called RefreshIndicator there.

But how do we use this handy UI element? And how does it play together with alternative state management solutions such as BLoC pattern?

How to use RefreshIndicator

RefreshIndicator is a usual widget that is supposed to be wrapped around the widget which you want to give the ability to be reloaded on pull down. Whenever the child‘s Scrollable ist overscrolled, the circular progress indicator is shown on top. When the user releases its finger / cursor and the indicator has been dragged far enough to the bottom, it will call the provided callback (onRefresh).

The widget’s constructor has two arguments. child represents the widget that should be refreshed on pull down. onRefresh is a callback function that is called when the refresh indicator has been dragged far enough to the bottom. This is the place where you typically trigger fetching new data.

Prerequisites

Before we jump right into the implementation, let’s build a sample widget we can test our implementation on.

Typically, there is a list of elements that is being refreshed when the indicator is shown. So let’s build a SampleListView widget that resembles this.

import 'package:flutter/material.dart';

class SampleListView extends StatelessWidget {
  const SampleListView({Key? key, required this.entries}) : super(key: key);

  final List<int> entries;

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: entries
          .map(
            (int e) => ListTile(
              leading: const Icon(Icons.android),
              title: Text('List element ${e + 1}'),
            ),
          )
          .toList(),
    );
  }
}

The widget expects a list of int. For every element in this list, it shows a ListTile with “List element xy”, xy being the index (+1) of the element.

The scenario

Because we are not dealing with actual dynamic data here, we define a recurring scenario once the user pulls down, the view idles for 2 seconds, resulting in the list being expanded by one element.

RefreshIndicator and StatefulWidget

import 'package:flutter/material.dart';
import 'package:flutter_app/sample_list_view.dart';

class RefreshWithStatefulWidget extends StatefulWidget {
  const RefreshWithStatefulWidget({Key? key}) : super(key: key);

  @override
  State<RefreshWithStatefulWidget> createState() =>
      _RefreshWithStatefulWidgetState();
}

class _RefreshWithStatefulWidgetState extends State<RefreshWithStatefulWidget> {
  List<int> entries = List<int>.generate(5, (int i) => i);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Refresh with StatefulWidget'),
      ),
      body: RefreshIndicator(
        onRefresh: () async {
          await Future.delayed(const Duration(seconds: 2));
          setState(() {
            entries.add(entries.length);
          });
        },
        child: SampleListView(
          entries: entries,
        ),
      ),
    );
  }
}

The list (entries) is initiated with 5 elements. After the user interaction with the RefreshIndicator, we alter the state by adding one element to the entries.

RefreshIndicator with StatefulWidget
RefreshIndicator with StatefulWidget

Alternative loading animation

So far, so good. But right now, the animation of the RefreshIndicator is shown until everything’s ready. What if we did not want to show the default loading animation? If we had a branded loading indicator all across our app, we might wanted to show it during every occurrence of a loading state. What if we had our app-wide loading indicator in a dialog?

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_app/sample_list_view.dart';

class RefreshWithStatefulWidget extends StatefulWidget {
  const RefreshWithStatefulWidget({Key? key}) : super(key: key);

  @override
  State<RefreshWithStatefulWidget> createState() =>
      _RefreshWithStatefulWidgetState();
}

class _RefreshWithStatefulWidgetState extends State<RefreshWithStatefulWidget> {
  List<int> entries = List<int>.generate(5, (int i) => i);
  bool loading = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Refresh with StatefulWidget'),
      ),
      body: RefreshIndicator(
        onRefresh: () async {
          _indicateLoading();
          Future.delayed(const Duration(seconds: 2)).then(
            (_) => _refresh(),
          );
        },
        child: loading
            ? const Center(
                child: CircularProgressIndicator(),
              )
            : SampleListView(
                entries: entries,
              ),
      ),
    );
  }

  void _refresh() {
    return setState(
      () {
        entries.add(entries.length);
        loading = false;
      },
    );
  }

  void _indicateLoading() {
    setState(() {
      loading = true;
    });
  }
}

We add a state property called loading which indicates whether the data is being loaded. It is supposed to be set to true when the user pulls down and be set to false again when the asynchronous operation is finished.

Depending on the value of loading we weither show a CircularProgressIndicator or the SampleListView.

The RequestIndicator disappears as soon as the onRefresh() method returns. That’s why we avoid using await to wait for our Future to be finished and use then instead.

RefreshIndicator with StatefulWidget and an alternative loading animation
RefreshIndicator with StatefulWidget and an alternative loading animation

Using await instead of then()

What if we want to use await? Actually, I like it much more than then() if I have sequential operations anyways. Because with then(), there is a steadily increasing nesting level. This decreases the readability of our code drastically.

How about letting the caller decide when to hide the RequestIndicator?

import 'dart:async';

import 'package:flutter/material.dart';

class RefreshableWidget extends StatelessWidget {
  const RefreshableWidget({
    Key? key,
    required this.child,
    required this.onRefresh,
  }) : super(key: key);

  final Widget child;
  final Function(Completer<void>) onRefresh;

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: () {
        final Completer<void> completer = Completer<void>();
        onRefresh(completer);
        return completer.future;
      },
      child: child,
    );
  }
}

I created a wrapper around the RefreshIndicator. The onRefresh() function being its constructor argument, has a Completer<void> type.

Now, what does that mean?

Completer is a way to produce Future objects. This is exactly what we want, because we want the caller to decide when the RefreshIndicator disappears. And it disappears whenever the onRefresh() function finishes. It’s an async function that returns a Future. We create a new function that returns the future property of our newly created Completer which is then passed to the onRefresh function of our RefreshableWidget.

Using our RefreshableWidget, our list looks like this:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Refresh with StatefulWidget'),
      ),
      body: RefreshableWidget(
        onRefresh: (Completer completer) async {
          completer.complete();
          _indicateLoading();
          await Future.delayed(const Duration(seconds: 2));
          _refresh();
        },
        child: loading
            ? const Center(
                child: CircularProgressIndicator(),
              )
            : SampleListView(
                entries: entries,
              ),
      ),
    );
  }

In this case, we let the RefreshIndicator disappear directly because we call completer.complete() as the first statement inside our callback. Now we can safely use await to wait for our time-intense operation.

How about using BLoC?

Now that we have abstracted the control over the disappearance of the, we can also easily use any other state management solution apart from StatefulWidget without having to worry about await statements.

The following example demonstrates a solution using the BLoC package.

part of 'refresh_cubit.dart';

class RefreshState extends Equatable {
  const RefreshState({this.entries = const []});

  final List<int> entries;

  @override
  List<Object?> get props => [entries];
}

class RefreshLoaded extends RefreshState {
  const RefreshLoaded({required List<int> entries}) : super(entries: entries);
}

class RefreshInitial extends RefreshState {
  const RefreshInitial() : super(entries: const <int>[0, 1, 2, 3, 4]);
}

class RefreshLoading extends RefreshState {}

First, we create a State that is to be emitted from our Cubit. It has three possible states: RefreshInitial, RefreshLoading and RefreshLoaded. RefreshInitial carries a list with 5 elements. RefreshLoading is supposed to be emitted when the user pulls down. Finally, RefreshLoaded should be emitted, when the time-intense operation is done and the data is fetched.

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';

part 'refresh_state.dart';

class RefreshCubit extends Cubit<RefreshState> {
  RefreshCubit() : super(const RefreshInitial());

  Future<void> refresh() async {
    emit(RefreshLoading());
    List<int> newList = List<int>.from(state.entries);
    newList.add(newList.length);
    await Future.delayed(const Duration(seconds: 2));
    emit(RefreshLoaded(entries: newList));
  }
}

In our Cubit, we only provide a single method: refresh(). It emits a loading state, adds an element to the list, and emits a loaded state.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_app/sample_list_view.dart';
import 'package:flutter_app/with_refreshable_widget/refresh_cubit.dart';
import 'package:flutter_app/with_refreshable_widget/refreshable_widget.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class RefreshWithRefreshableWidget extends StatelessWidget {
  const RefreshWithRefreshableWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider<RefreshCubit>(
      create: (BuildContext context) => RefreshCubit(),
      child: BlocBuilder<RefreshCubit, RefreshState>(
        builder: (BuildContext context, RefreshState state) {
          return RefreshableWidget(
            onRefresh: (Completer<void> completer) async {
              completer.complete();
              context.read<RefreshCubit>().refresh();
            },
            child: _buildScaffold(context, state),
          );
        },
      ),
    );
  }

  Scaffold _buildScaffold(BuildContext context, RefreshState state) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Refresh with RefreshableWidget'),
      ),
      body: _buildBody(context, state),
    );
  }

  Widget _buildBody(BuildContext context, RefreshState state) {
    if (state is RefreshLoading) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    return SampleListView(
      entries: state.entries,
    );
  }
}

In the widget, we can now complete the future before calling the Cubit‘s refresh method. If we wanted to give the control over the disappearance of the indicator to the cubit, we could expect a parameter in the refresh method.

Conclusion

Flutter already provides a solution for handling the pull-to-refresh gesture which is very straightforward in its usage. However, when using await or there is the desire to show an alternative loading animation, there are some some things to consider.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment