Skip to content

Latest commit

 

History

History
424 lines (337 loc) · 18.2 KB

README.md

File metadata and controls

424 lines (337 loc) · 18.2 KB

❤️ Sponsor Buy Me A Coffee

watch_it

A simple state management solution powered by get_it.

This package is the successor of the get_it_mixin, here you can find what's new

We now have a support discord server https://discord.gg/ZHYHYCM38h

This package offers a set of functions to watch data registered with GetIt. Widgets that watch data will rebuild automatically whenever that data changes.

Supported data types that can be watched are Listenable / ChangeNotifier, ValueListenable / ValueNotifier, Stream and Future. On top of that there are several other powerful functions to use in StatelessWidgets that normally would require a StatefulWidget.

ChangeNotifier based example:

 // Create a ChangeNotifier based model
 class UserModel extends ChangeNotifier {
   get name => _name;
   String _name = '';
   set name(String value){
     _name = value;
     notifyListeners();
   }
   ...
 }

 // Register it
 di.registerSingleton<UserModel>(UserModel());

 // Watch it
 class UserNameText extends WatchingWidget {
   @override
   Widget build(BuildContext context) {
     final userName = watchPropertyValue((UserModel m) => m.name);
     return Text(userName);
   }
 }

Whenever the name property changes the watchPropertyValue function will trigger a rebuild and return the latest value of name.

Accessing GetIt

WatchIt exports the default instance of get_it as a global variable di (dependency injection) which lets you access it from anywhere in your app. To access any get_it registered object you only have to type di<MyType>() instead of GetIt.I<MyType>(). If you prefer to use GetIt.I or you have your own global variable that's fine too as they all will use the same instance of GetIt.

If you want to use a different instance of get_it you can pass it to the functions of this library as an optional parameter.

Watching Data

Where WatchIt really shines is data-binding. It comes with a set of watch methods to rebuild a widget when data changes.

Imagine you had a very simple shared model, with multiple fields, one of them being country:

class Model {
    final country = ValueNotifier<String>('Canada');
    ...
}
di.registerSingleton<Model>(Model());

You could tell your view to rebuild any time country changes with a simple call to watchValue:

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    String country = watchValue((Model x) => x.country);
    ...
  }
}

There are various watch methods, for common types of data sources, including ChangeNotifier, ValueNotifier, Stream and Future:

API Description
watch observes any Listenable you have access to
watchIt observes any Listenable registered in get_it
watchValue observes a ValueListenable property of an object registered in get_it
watchPropertyValue observes a property of a Listenable object and trigger a rebuild whenever the Listenable notifies a change and the value of the property changes
watchStream observes a Stream and triggers a rebuild whenever the Stream emits a new value
watchFuture observes a Future and triggers a rebuild whenever the Future completes

To be able to use the functions you have either to derive your widget from WatchingWidget or WatchingStatefulWidget or use the WatchItMixin or WatchItStatefulWidgetMixin in your widget class and call the watch functions inside the their build functions.

Just call watch* to listen to the data type you need, and WatchIt will take care of cancelling bindings and subscriptions when the widget is destroyed.

The primary benefit to the watch methods is that they eliminate the need for ValueListenableBuilders, StreamBuilder etc. Each binding consumes only one line and there is no nesting. Making your code more readable and maintainable. Especially if you want to bind more than one variable.

Here we watch three ValueListenable which would normally be three builders, 12+ lines of code and several levels of indentation. With WatchIt, it's three lines:

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    bool loggedIn = watchValue((UserModel x) => x.isLoggedIn);
    String userName = watchValue((UserModel x) => x.user.name);
    bool darkMode = watchValue((SettingsModel x) => x.darkMode);
    ...
  }
}

This can be used to eliminate StreamBuilder and FutureBuilder from your UI as well:

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    final currentUser = watchStream((UserModel x) => x.userNameUpdates, initialValue: 'NoUser');
    final ready = watchFuture((AppModel x) => x.initializationReady, initialValue: false).data;
    bool appIsLoading = ready == false || currentUser.hasData == false;
    
    if(appIsLoading) return CircularProgressIndicator();
    return Text(currentUser.data);    
  }
}

Side Effects / Event Handlers

Instead of rebuilding, you might instead want to show a toast notification or dialog when a Stream emits a value or a ValueListenable changes. Normally you would need to use a Stateful widget to be able to subscribe and unsubscribe your handler function.

To run an action when data changes you can use the register*Handler methods:

API Description
.registerHandler Add an event handler for a ValueListenable
.registerStreamHandler Add an event handler for a Stream
.registerFutureHandler Add an event handler for a Future
.registerChangeNotifierHandler Add an event handler for a ChangeNotifier

The registerHandler, registerStreamHandler and registerFutureHandler methods have an optional select delegate parameter that can be used to watch a specific field of an object in GetIt. The second parameter is the action which will be triggered when that field changes:

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    registerHandler(
        select: (Model x) => x.name,
        handler: (context, value, cancel) => showNameDialog(context, value));
    ...
  }
}

In the example above you see that the handler function receives the value that is returned from the select delegate ((Model x) => x.name), as well as a cancel function that the handler can call to cancel registration at any time.

In case of the registerChangeNotifierHandler the handler function receives the ChangeNotifier object itself as well as a cancel function that the handler can call to cancel registration at any time.

class Counter extends ChangeNotifier {
  int value = 0;
  void increment() {
    value++;
    notifyListeners();
  }
}

di.registerSingleton<Counter>(Counter());

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    registerChangeNotifierHandler(
        handler: (context, Counter value, cancel) {
          if (value.value == 3) {
            SnackBar snackbar = SnackBar(
              content: Text('Value is 3'),
            );

            Scaffold.of(context).showSnackBar(snackbar);
          }
        }
    );
    ...
  }
}

As with watch calls, all registerHandler calls are cleaned up when the Widget is destroyed. If you want to register a handler for a local variable all the functions offer a target parameter.

Rules

There are some important rules to follow in order to avoid bugs with the watch or register* methods:

  • watch methods must be called within build()
    • It is good practice to define them at the top of your build method
  • must be called on every build, in the same order (no conditional watching). This is similar to flutter_hooks.
  • do not use them inside of a builder as it will break the mixins ability to rebuild

If you want to know more about the reasons for this rule check out Lifting the magic curtain

The watch functions in detail:

Watching Listenable / ChangeNotifier

watch observes any Listenable that you pass as parameter and triggers a rebuild whenever it notifies a change.

T watch<T extends Listenable>(T target);

That listenable is passed directly in as a parameter which means it could be some local variable/property or also come from get_it. Like

final userName = watch(di<UserModel>()).name;

given that UserManager is a Listenable (eg. ChangeNotifier).

If all of the following functions don't fit your needs you can probably use this one by manually providing the Listenable that should be observed.

Example:

class CounterModel with ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count += 1;
    notifyListeners();
  }
}

final counter = CounterModel();
...

Widget build(BuildContext context) {
  watch(counter);

  return Text(counter.count);
}

Watching Listenable inside GetIt

watchIt observes any Listenable registered with the type T in get_it and triggers a rebuild whenever it notifies a change. It's basically a shortcut for watch(di<T>()). instanceName is the optional name of the instance if you registered it with a name in get_it. getIt is the optional instance of get_it to use if you don't want to use the default one. 99% of the time you won't need this.

T watchIt<T extends Listenable>({String? instanceName, GetIt? getIt}) {

If we take our Listenable UserModel from above we could watch it like

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    final userName = watchIt<UserModel>().name;
    return Text(userName);
  }
}

Watching only one property of a Listenable

If the Listenable parent object that you watch with watchIt notifies often because other properties have changed that you don't want to watch, the widget would rebuild without any need. In this case you can use watchPropertyValue

R watchPropertyValue<T extends Listenable, R>(R Function(T) selectProperty,
    {T? target, String? instanceName, GetIt? getIt});

It will only trigger a rebuild if the watched listenable notifies a change AND the value of the selected property has really changed.

final userName = watchPropertyValue<UserManager, String>((m) => m.userName);

Could be an example. Or even more expressive and concise:

final userName = watchPropertyValue((UserManager m) => m.userName);

which lets the analyzer infer the type of T and R.

If you have a local Listenable and you want to observe only a single property you can pass it as [target] and omit the generic parameter:

final userManager = UserManager();
...
// inside build()
final userName = watchPropertyValue((m) => m.userName, target: userManger);

Watching `ValueListenable / ValueNotifier

R watchValue<T extends Object, R>(ValueListenable<R> Function(T) selectProperty,
    {String? instanceName, GetIt? getIt}) {

watchValue observes a ValueListenable (e.g. a ValueNotifier) property of an object registered in get_it. It triggers a rebuild whenever the ValueListenable notifies a change and returns its current value. It's basically a shortcut for watchIt<T>().value As this is a common scenario it allows us a type safe concise way to do this.

class UserManager
{
  final userName = ValueNotifier<String>('James');
}

// register it in GetIt
di.registerSingleton(UserManager);

// watch it
Widget build(BuildContext context) {
  final userName = watchValue<UserManager, String>((user) => user.userName);

  return Text(userName);
}

is an example of how to use it. We can use the strength of generics to infer the type of the property and write it even more expressive like this:

final userName = watchValue((UserManager user) => user.userName);

instanceName is the optional name of the instance if you registered it with a name in get_it. getIt is the optional instance of get_it to use if you don't want to use the default one. 99% of the time you won't need this.

Watching a local ValueListenable/ValueNotifier

You might wonder why watchValue has no target parameter. The reason is that Dart doesn't support positional optional parameters in combination with named optional parameters. This would require that you always would have to add a parameter name to the select function when using it in the most common way to watch a ValueListenable property of an object inside GetIt. As there is already another option to watch local ValueListenable by using watch I decided to drop the target property from watchValue. As all ValueListenable are also Listenable we can watch them with watch():

final counter = ValueNotifier<int>();

Widget build(BuildContext context) {
  final counterValue = watch(counter).value;

  return Text(counterValue);
}

This will trigger a rebuild every time the counter.value changes.

Watching Streams and Futures

watchStream and watchFuture follow nearly the same pattern as the above watch functions.

class TestStateLessWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final currentUser = watchStream((Model x) => x.userNameUpdateStream, 'NoUser');
    final ready =
        watchFuture((Model x) => x.initializationReady,false).data;

    return Column(
      children: [
        if (ready != true || !currentUser.hasData) // in case of an error ready could be null
         CircularProgressIndicator()
         else
        Text(currentUser.data),
      ],
    );
  }
}

Please check the API docs for details.

isReady() and allReady()

A common use case is to toggle a loading state when side effects are in-progress. To check whether any async registration actions inside GetIt have completed you can use allReady() and isReady<T>(). These methods return the current state of any registered async operations and a rebuild is triggered when they change. If you only want the onReady handler to be called once set callHandlerOnlyOnce==true

class MyWidget extends StatelessWidget with WatchItMixin {
  @override
  Widget build(BuildContext context) {
    allReady(onReady: (context)
      => Navigator.of(context).pushReplacement(MainPageRoute()));
    return CircularProgressIndicator();
  }
}

callOnce() and onDispose()

If you want to execute a function only on the first built (even in in a StatelessWidget), you can use the callOnce function anywhere in your build function. It has an optional dispose handler which will be called when the widget is disposed.

To dispose anything when the widget is disposed you can use call onDispose anywhere in your build function

Check out the GetIt docs for more information on the isReady and allReady functionality: https://pub.dev/packages/get_it

Pushing a new GetIt Scope

With pushScope() you can push a scope when a Widget/State is mounted, and automatically drop it when the Widget/State is destroyed. You can pass an optional init or dispose function.

void pushScope({void Function(GetIt getIt) init, void Function() dispose});

The newly created Scope gets a unique name so that it is ensured the right Scope is dropped even if you push or drop manually other Scopes.

The WatchingWidgets

Some people don't like mixins so WatchIt offers two Widgets that can be used instead.

  • WatchingWidget - can be used instead of StatelessWidget
  • WatchingStatefulWidget - instead of StatefulWidget

Lifting the magic curtain

*It's not necessary to understand the following chapter to use WatchIt successfully. You might be wondering how on earth is this possible, that you can watch multiple objects at the same time without passing some identifier to any of the watch functions. The reality might feel a bit like a hack but the advantages that you get from it justify it absolutely. When applying the WatchItMixin to a Widget you add a handler into the build mechanism of Flutter that makes sure that before the build function is called a _watchItState object that contains a reference to the Element of this widget plus a list of WatchEntrys is assigned to a private global variable. Over this global variable the watch* functions can access the Element to trigger a rebuild. With each watch* function call a new WatchEntry is added to that list and a counter is incremented. When a rebuild is triggered the counter is reset and incremented again with each watch* call so that it can access the data it stored during the last build. Now it should be clear why the watch* functions always have to happen in the same order and no conditionals are allowed that would change the order between two builds because then the relation between watch* call and its WatchEntry would be messed up. If you think that all sounds very familiar to you then probably because the exact same mechanism is used by flutter_hooks or React Hooks.

Find out more!

To learn more about GetIt, watch the presentation: GetIt in action By Thomas Burkhart, in there the predecessor of this package called ´get_it_mixin´ is described but the video should still be helpful for the GetIt part.

What's different from the get_it_mixin

Two main reasons lead me to replace the get_it_mixin package with watch_it

  • The name `get_it_mixin seemed not to catch with people and only a fraction of my get_it users used it.
  • The API naming wasn't as intuitive as I thought when I first wrote them.

These are the main differences:

  • Widgets now can be const!
  • a reduced API with more intuitive naming.The old package had too many functions which were only slight variations of each other. You can easily achieve the same functionality with the functions of this package.
  • no get/getX functions anymore because you can just use the included global get_it instance di<T>.
  • only one mixin for all Widgets. You only need to apply it to the widget and no mixin for States as now all watch* functions are global functions.

Please let me know if you miss anything