listenable_future_builder 1.0.0-beta copy "listenable_future_builder: ^1.0.0-beta" to clipboard
listenable_future_builder: ^1.0.0-beta copied to clipboard

A general purpose Builder widget that allows you to do async work on controller initialization

listenable_future_builder #

badge

codecov

Introduction #

We often use ChangeNotifier and ValueNotifier<> as controllers behind StatelessWidgets. However, there are two issues:

  • A vanilla StatelessWidget cannot hold onto the controller because it could rebuild and lose the state at any time.
  • We sometimes need to do async work before we can use the controller.

ListenableFutureBuilder solves these issues by acting like a hybrid of AnimatedBuilder and FutureBuilder.

Do this:

Live Sample

import 'package:flutter/material.dart';
import 'package:listenable_future_builder/listenable_future_builder.dart';

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        primarySwatch: Colors.blue,
      ),
      home: ListenableFutureBuilder<ValueNotifier<int>>(
        listenable: getController,
        builder: (context, child, snapshot) => Scaffold(
          appBar: AppBar(),
          body: Center(
            child: switch (snapshot) {
              AsyncSnapshot(hasData: true) => Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    const Text(
                      'You have pushed the button this many times:',
                    ),
                    Text(
                      '${snapshot.data!.value}',
                      style: Theme.of(context).textTheme.headlineMedium,
                    ),
                  ],
                ),
              AsyncSnapshot(hasError: true) => const Text('Error'),
              AsyncSnapshot() => const CircularProgressIndicator.adaptive()
            },
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => snapshot.data?.value++,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
        ),
      ),
      debugShowCheckedModeBanner: false,
    ),
  );
}

Future<ValueNotifier<int>> getController() async =>
    Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

Instead of this:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ValueNotifier<int>? _controller;

  @override
  void initState() {
    super.initState();
    _getController();
  }

  Future<void> _getController() async {
    final controller = await getController();
    setState(() {
      _controller = controller;
    });
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
        theme: ThemeData(
          useMaterial3: true,
          primarySwatch: Colors.blue,
        ),
        home: Scaffold(
          appBar: AppBar(),
          floatingActionButton: FloatingActionButton(
            onPressed: () => _controller!.value++,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
          body: _controller != null
              ? AnimatedBuilder(
                  animation: _controller!,
                  builder: (context, child) => Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        const Text(
                          'You have pushed the button this many times:',
                        ),
                        Text(
                          '${_controller!.value}',
                          style: Theme.of(context).textTheme.headlineMedium,
                        ),
                      ],
                    ),
                  ),
                )
              : const CircularProgressIndicator.adaptive(),
        ),
        debugShowCheckedModeBanner: false,
      );
}

Future<ValueNotifier<int>> getController() async =>
    Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

Notice that the second version is far more verbose than the version with ListenableFutureBuilder. This is because ListenableFutureBuilder creates your controller for you on the first call and hangs onto the controller for the lifespan of the widget. That means you don't need to create a StatefulWidget / State pair to hold onto the controller.

Getting Started #

Add listenable_future_builder to your pubspec.yaml dependencies:

dependencies:
  flutter:
    sdk: flutter
  listenable_future_builder: ^[CURRENT-VERSION]

Import the package in your Dart file:

import 'package:listenable_future_builder/listenable_future_builder.dart';

Usage #

ListenableFutureBuilder works with any Listenable, such as a ChangeNotifier or ValueNotifier. To use ListenableFutureBuilder, provide a listenable function that returns a Future of your Listenable controller and a builder function that defines how to build the widget depending on the state of the AsyncSnapshot.

Here's an example of how to use ListenableFutureBuilder with a ValueNotifier. The builder function should check the state of the AsyncSnapshot to determine if the data is ready, an error occurred, or if it's still loading. We display a CircularProgressIndicator while waiting, show an error message if an error occurs, and display the value once it's available.

Clicking the floating action button will pop up an input dialog, and if you enter a value, it will create a new list with the new item. The ListView will display all the items in the current list.

Live Sample

import 'package:flutter/material.dart';
import 'package:listenable_future_builder/listenable_future_builder.dart';

void main() => runApp(
      MaterialApp(
        theme: ThemeData(
          useMaterial3: true,
          primarySwatch: Colors.purple,
        ),
        debugShowCheckedModeBanner: false,
        home: ListenableFutureBuilder<ValueNotifier<List<String>>>(
          listenable: () => Future<ValueNotifier<List<String>>>.delayed(
              const Duration(seconds: 2),
              () => ValueNotifier<List<String>>([])),
          builder: (context, child, snapshot) => Scaffold(
            appBar: AppBar(title: const Text('To-do List')),
            body: Center(
              child: switch (snapshot) {
                AsyncSnapshot(hasData: true) => ListView.builder(
                    itemCount: snapshot.data!.value.length,
                    itemBuilder: (context, index) =>
                        ListTile(title: Text(snapshot.data!.value[index])),
                  ),
                AsyncSnapshot(hasError: true) => const Text('Error'),
                AsyncSnapshot() => const CircularProgressIndicator.adaptive()
              },
            ),
            floatingActionButton: FloatingActionButton(
              onPressed: () => showDialog(
                context: context,
                builder: (context) => AlertDialog(
                  title: const Text('Add a new to-do item'),
                  content: TextField(
                    onSubmitted: (value) {
                      Navigator.of(context).pop();
                      List<String> updatedList =
                          List<String>.from(snapshot.data!.value);
                      updatedList.add(value);
                      snapshot.data!.value = updatedList;
                    },
                  ),
                ),
              ),
              tooltip: 'Add a new item',
              child: const Icon(Icons.add),
            ),
          ),
        ),
      ),
    );

Disposal #

In some cases, you may need to perform cleanup operations when the ListenableFutureBuilder is disposed. This is necessary when listeners hold resources that need to be released when the widget is no longer in use. To handle disposal, provide a disposeListenable function. This function will be called with the Listenable instance when the ListenableFutureBuilder is disposed.

In this example, we create a custom ChangeNotifier class that manages a timer, and we use ListenableFutureBuilder to display the timer's current value. We also provide a disposeListenable function to stop the timer and clean up resources when the widget is no longer in use. The disposeListenable function stops the timer and releases the resources held by the TimerNotifier when the ListenableFutureBuilder is disposed. This helps to prevent resource leaks and ensure proper cleanup of the Listenable. This example is stateful so we can toggle the _showListenableFutureBuilder with the floating action button. Clicking this removes the ListenableFutureBuilder from the tree, which triggers the disposeListenable function.

Live Sample

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:listenable_future_builder/listenable_future_builder.dart';

class TimerNotifier extends ChangeNotifier {
  Timer? _timer;
  int _seconds = 0;

  TimerNotifier() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      _seconds++;
      notifyListeners();
    });
  }

  int get seconds => _seconds;

  void disposeTimer() {
    _timer?.cancel();
    _timer = null;
  }
}

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      home: MyApp(),
      theme: ThemeData(
        useMaterial3: true,
        primarySwatch: Colors.orange,
      ),
    ),
  );
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  bool _showListenableFutureBuilder = true;

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(),
        body: Center(
          child: _showListenableFutureBuilder
              ? ListenableFutureBuilder<TimerNotifier>(
                  listenable: getTimerNotifier,
                  builder: (context, child, snapshot) => switch (snapshot) {
                    AsyncSnapshot(hasData: true) =>
                      Text('Elapsed seconds: ${snapshot.data!.seconds}'),
                    AsyncSnapshot(hasError: true) => const Text('Error'),
                    AsyncSnapshot() =>
                      const CircularProgressIndicator.adaptive()
                  },
                  disposeListenable: (timerNotifier) async =>
                      timerNotifier.disposeTimer(),
                )
              : const Text('ListenableFutureBuilder removed.'),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => setState(
            () => _showListenableFutureBuilder = !_showListenableFutureBuilder,
          ),
          tooltip: 'Toggle ListenableFutureBuilder',
          child: const Icon(Icons.toggle_on),
        ),
      );
}

Future<TimerNotifier> getTimerNotifier() async {
  await Future.delayed(const Duration(seconds: 2));
  return TimerNotifier();
}

Custom Listenable #

You may want to implement your own version of the Listenable class. This example displays random colors when you click the floating action button. We create a ColorController class that extends Listenable. This controller allows you to change the color of the ColoredBox widget by calling the changeColor method. The ListenableFutureBuilder is used to build the widget tree with the ColorController, and a FloatingActionButton is provided to change the color randomly. The disposeListenable function is called when the ListenableFutureBuilder is removed from the widget tree, and it disposes of the ColorController.

Live Sample

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:listenable_future_builder/listenable_future_builder.dart';

class ColorController extends Listenable {
  final List<VoidCallback> _listeners = [];

  Color _color = Colors.red;

  Color get color => _color;

  void changeColor(Color newColor) {
    _color = newColor;
    notifyListeners();
  }

  @override
  void addListener(VoidCallback listener) {
    _listeners.add(listener);
  }

  @override
  void removeListener(VoidCallback listener) {
    _listeners.remove(listener);
  }

  void notifyListeners() {
    for (final listener in _listeners) {
      listener();
    }
  }

  void dispose() {
    _listeners.clear();
  }
}

void main() => runApp(
      MaterialApp(
        theme: ThemeData(
          useMaterial3: true,
          primarySwatch: Colors.green,
        ),
        debugShowCheckedModeBanner: false,
        home: ListenableFutureBuilder<ColorController>(
          listenable: () => Future.delayed(
              const Duration(seconds: 2), () => ColorController()),
          builder: (context, child, snapshot) => Scaffold(
            body: Center(
              child: switch (snapshot) {
                AsyncSnapshot(hasData: true) => ColoredBox(
                    color: snapshot.data!.color,
                    child: const SizedBox(width: 100, height: 100),
                  ),
                AsyncSnapshot(hasError: true) => const Text('Error'),
                AsyncSnapshot() => const CircularProgressIndicator.adaptive()
              },
            ),
            floatingActionButton: FloatingActionButton(
              onPressed: () => snapshot.data?.changeColor(
                  Colors.primaries[Random().nextInt(Colors.primaries.length)]),
              tooltip: 'Change color',
              child: const Icon(Icons.color_lens),
            ),
          ),
          disposeListenable: (colorController) async =>
              colorController.dispose(),
        ),
      ),
    );

Contributing #

Contributions are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you'd like to contribute code, feel free to fork the repository and submit a pull request.

15
likes
130
pub points
18%
popularity

Publisher

verified publisherchristianfindlay.com

A general purpose Builder widget that allows you to do async work on controller initialization

Repository (GitHub)
View/report issues

Documentation

API reference

License

BSD-3-Clause (LICENSE)

Dependencies

flutter

More

Packages that depend on listenable_future_builder