Displaying a loading overlay or progress HUD in Flutter

Displaying a loading overlay or progress HUD in Flutter

Christos's photo
Christos
·Mar 20, 2022·

8 min read

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

Introduction

Sometimes, in an app, you want to perform an asynchronous operation and want to prevent the user from tapping/using the app while this operation is in progress. A simple example would be when you create a new item/to-do in your to-do list app. After tapping on "save" or an equivalent button, the save operation would very likely be asynchronous; it might involve storing data to local storage, performing a network request, or both. This is usually a very quick operation, taking less than a second. But sometimes it could take a bit longer. During that time, you don't want users accidentally tapping the save button twice, or changing inputs.

The simplest solution to this would be to display a loading overlay while the operation is in progress. To do this, we're going to need a widget! And in this tutorial, we will do just that.

Defining a loading overlay, or progress HUD

To make sure we're on the same page, this is what the end result will be like:

Loading overlay

Just a circular progress indicator with a semi-opaque dark background, covering the current screen and preventing any user input. This is typically called an overlay or a heads-up-display (HUD).

Re-inventing the wheel

Before we get started, you might not be interested in writing a custom solution for this and would rather use a package for this. If this is the case, there are tens of packages to do just this. Just search for "loading overlay", "loader overlay", or "progress hud" on pub.dev. I haven't used any personally, but loader_overlay looks well maintained and has a nice API.

At the same time, this is almost trivial to implement, so I'd encourage you to just use your own solution (or copy this one!), and simply adjust it to work for you. Introducing an entirely new package to only do this might be a bit too much.

Building the overlay in 2 steps

In this tutorial, we will build the loading overlay widget in 3 steps, building on top of the example Flutter counter app. In the first step, we'll simply make the overlay work on the main screen of the app, without any re-usable code. In the second step, we'll extract our overlay code to a new widget that we can re-use anywhere.

If you're just here for the code, just go ahead and skip to step #2, or see the code in the repository here.

Step #1: Using a Stack

In this tutorial we'll be working with the example Flutter app. If you want to follow along, you can create this with the following command:

flutter create <app_name>

The loading overlay widget is actually very simple; it is just the current screen/view/widget, wrapped in a Stack, with the semi-opaque overlay and a centered circular progress indicator at the top of the stack.

@override
Widget build(BuildContext context) {
  return Stack(
    children: [
      Scaffold(
        ...
      ),
      const Opacity(
        opacity: 0.8,
        child: ModalBarrier(dismissible: false, color: Colors.black),
      ),
      const Center(
        child: CircularProgressIndicator(),
      ),
    ],
  );
}

With the change above, we now have a permanent loading overlay in front of the Scaffold. Let's introduce a new state variable, _isLoading, and only show the Opacity and Center widgets if its value is true.

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  bool _isLoading = false;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Scaffold(
          ...
        ),
        if (_isLoading)
          const Opacity(
            opacity: 0.8,
            child: ModalBarrier(dismissible: false, color: Colors.black),
          ),
        if (_isLoading)
          const Center(
            child: CircularProgressIndicator(),
          ),
      ],
    );
  }
}

Let's make a small tweak to the _incrementCounter method to test this. When the method is called, we set _isLoading to true, wait 3 seconds, and then set it back to false and increment the counter.

void _incrementCounter() async {
  setState(() {
    _isLoading = true;
  });
  await Future.delayed(const Duration(seconds: 3));
  setState(() {
    _counter++;
    _isLoading = false;
  });
}

And that's it! Easy, right? Now, this loading overlay code is mixed up with the other view logic, and if we have to push a new view on top of this one (Navigator.push(context, ...)), we'd need to implement this all over again.

Step #2: Extracting the LoadingOverlay widget

// loading_overlay.dart
class LoadingOverlay extends StatefulWidget {
  const LoadingOverlay({Key? key, required this.child}) : super(key: key);

  final Widget child;

  static _LoadingOverlayState of(BuildContext context) {
    return context.findAncestorStateOfType<_LoadingOverlayState>()!;
  }

  @override
  State<LoadingOverlay> createState() => _LoadingOverlayState();
}

class _LoadingOverlayState extends State<LoadingOverlay> {
  bool _isLoading = false;

  void show() {
    setState(() {
      _isLoading = true;
    });
  }

  void hide() {
    setState(() {
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        widget.child,
        if (_isLoading)
          const Opacity(
            opacity: 0.8,
            child: ModalBarrier(dismissible: false, color: Colors.black),
          ),
        if (_isLoading)
          const Center(
            child: CircularProgressIndicator(),
          ),
      ],
    );
  }
}

Notice the LoadingOverlay.of() function, which actually returns _LoadingOverlayState. In the state we expose two public functions, show and hide, which respectively show and hide the loading overlay.

The context.findAncestorStateOfType<T>() function returns any ancestor matching this state class, which can be null if there is no such ancestor. In this example, we can simply assume there will always be one and use !. Or, we could assert it is not null before returning it, throwing an error with a useful message.

Now, in order for _LoadingOverlayState to be an "ancestor" in our app's widget tree, we need to wrap our main view with the LoadingOverlay widget, by making the following changes:

// MyApp
@override
Widget build(BuildContext context) {
  return MaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
    home: const LoadingOverlay(
      child: MyHomePage(title: 'Loading Overlay'),
    ),
  );
}

Note that we reverted the changes initially made in _MyHomePageState, as the Stack and overlay widgets have now been moved to the LoadingOverlay widget.

By simply passing MyHomePage as a child in LoadingOverlay, we can toggle the loading overlay at any point within the MyHomePage widget or any of its child widgets by calling LoadingOverlay.of(context).

To see our changes in action, let's change the _incrementCounter method again.

void _incrementCounter() async {
  LoadingOverlay.of(context).show();
  await Future.delayed(const Duration(seconds: 3));
  setState(() {
    _counter++;
  });
  LoadingOverlay.of(context).hide();
}

We now have a reusable LoadingOverlay widget that we can use any time we want to toggle such an overlay. No external packages needed, and all in a stateless widget of less than 50 lines of code.

Note that if we push a new view/widget on top of this one, the context there will not have a LoadingOverlay ancestor. So we'd need to wrap any newly pushed views with another LoadingOverlay.

Navigator.push(
    context,
    MaterialPageRoute(
        builder: (_) => const LoadingOverlay(child: NewPage())));

And that's it for the main part of this tutorial. Next, we will make some small improvements to the LoadingOverlay widget and make it more customizable. After that, in the following step, we will make the widget stateless rather than stateful. If you're not interested, feel free to skip ahead to the end of the post.

Improvement #1: Tweaks

In some cases, the operations you show the loading overlay for might take less than a second, or even half a second. In such cases, you might still want to show an overlay to block user interactions, but not the spinner/circular progress indicator.

We can make a small tweak to our LoadingOverlay widget to delay the circular progress indicator by a duration of our choice, by using a FutureBuilder.

class LoadingOverlay extends StatefulWidget {
  const LoadingOverlay({
    Key? key,
    required this.child,
    this.delay = const Duration(milliseconds: 500),
  }) : super(key: key);

  final Widget child;
  final Duration delay;

  ...
}
// _LoadingOverlayState
...
Center(
  child: FutureBuilder(
    future: Future.delayed(widget.delay),
    builder: (context, snapshot) {
      return snapshot.connectionState == ConnectionState.done
          ? const CircularProgressIndicator()
          : const SizedBox();
    },
  ),
),
...

The delay parameter is optional and defaults to 500 milliseconds.

Another change we can do is blur the background when we show the overlay. This can be done, of course, with another widget; BackdropFilter and ImageFilter.blur.

BackdropFilter(
  filter: ImageFilter.blur(sigmaX: 4.0, sigmaY: 4.0),
  child: const Opacity(
    opacity: 0.8,
    child: ModalBarrier(dismissible: false, color: Colors.black),
  ),
),

Blurred overlay

Another potential tweak would be to show something else rather than a CircularProgressIndicator. If you want this to vary depending on what you use it for, it could just be an additional Widget parameter that you can simply pass when you create a LoadingOverlay. Or it could even be optional and default to CircularProgressIndicator.

Improvement #2: Going stateless

I'm a big fan of stateless widgets, and try to avoid stateful widgets when I can. For this loading overlay widget, the only state we need to manage and change is a boolean value that determines whether the overlay is shown or not. Rather than with a stateful widget, we could achieve this by using a ValueNotifier and a ValueListenableBuilder.

class LoadingOverlayAlt extends StatelessWidget {
  LoadingOverlayAlt({Key? key, required this.child})
      : _isLoadingNotifier = ValueNotifier(false),
        super(key: key);

  final ValueNotifier<bool> _isLoadingNotifier;
  final Widget child;

  static LoadingOverlayAlt of(BuildContext context) {
    return context.findAncestorWidgetOfExactType<LoadingOverlayAlt>()!;
  }

  void show() {
    _isLoadingNotifier.value = true;
  }

  void hide() {
    _isLoadingNotifier.value = false;
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: _isLoadingNotifier,
      child: child,
      builder: (context, isLoading, child) {
        return Stack(
          children: [
            child!,
            if (isLoading)
              const Opacity(
                opacity: 0.8,
                child: ModalBarrier(dismissible: false, color: Colors.black),
              ),
            if (isLoading)
              const Center(
                child: CircularProgressIndicator(),
              ),
          ],
        );
      },
    );
  }
}

We simply initialize the value notifier with a default false value, and expose methods that update its value. The ValueListenableBuilder will be rebuilt every time the notifier's value changes, so we end up with the same result as doing a setState with a stateful widget.

Note that we now use context.findAncestorWidgetOfExactType<T>() instead of context.findAncestorStateOfType<T>(), as the widget is now stateless, and the show and hide methods are part of this stateless widget.

Surprisingly (for me, at least!), switching from a stateful to a stateless widget does not really decrease the lines of code in the widget by a lot. And since we initialize the ValueNotifier in the constructor, the constructor can no longer be constant. So in the end, this might be less of an improvement but more of a matter of preference.

Wrapping up

In this tutorial, we created a loading overlay widget and implemented functionality to easily display and hide it, without external packages and less than 70 lines of code. Without the spinner delay and blur filter tweaks, this is even less, at around 50 lines.

You can find the full source code here.

If you found this helpful and would like to be notified of any future tutorials, please sign up with your email below.

Did you find this article valuable?

Support Christos by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this