utils topic

utils

Helpers that ship with the package but belong to no scope family. They are exported because the scopes themselves are built on them and an application tends to want the same tools; nothing here needs a scope above it.

helper what it is
ListenableListenExtension.listen Stream-style subscription to a Listenable; from package:scopo/listenable.dart
ListenableSelectExtension.select a listener called only when a selected value changes; from package:scopo/listenable.dart
ListenableSelector the widget form of that selector
StateAsNotifier a mixin that makes a State listenable
ProgressTracker, Progress step counting for an initialization
ScreenshotReplacer freezes a subtree into the image it last painted
IsBuildingExtension is a build running, and how to wait for the end of it

Listening to a Listenable

listen and select are not in package:scopo/scopo.dart. They come from an import of their own:

import 'package:scopo/listenable.dart';

Both are extensions on the framework's own Listenable, where another package is free to put a listen and a select of its own. Exported with the scopes, they met those in every file that imported both: listen was ambiguous, and the other package's select was quietly replaced by this one — the more specific of the two — so the call failed with errors about the wrong signature. Kept apart, they arrive only in a file that asks for them. ListenableSelector below is a widget, collides with nothing, and stays in the main import.

addListener and removeListener require keeping the callback around, which is why a closure cannot be unsubscribed without storing it somewhere. listen returns the subscription instead, the way Stream does:

final subscription = counter.listen(() => print(counter.value));
// Other work can run before cancelling the subscription.
subscription.cancel();

CompositeListenableSubscription collects several of them and cancels the lot in one call.

select narrows the callback to one value:

final subscription = model.select(
  (model) => model.userName,
  (model, userName) => print(userName),
);

The listener runs only when the selected value changes. By default that means !=, and shouldNotify: replaces the test with one of your own. It answers the same question, so true means changed: notIdentical for a value that is replaced rather than mutated, a field-by-field comparison for a record, and so on. Passing identical there reports the opposite of what it is asked — the same object counts as a change and a replacement goes unnoticed. This is the same idea select uses on a scope, without a widget tree involved.

ListenableSelector is the widget wrapping the same mechanism:

ListenableSelector<Counter, int>(
  listenable: counter,
  selector: (counter) => counter.value,
  builder: (context, counter, value, child) => Text('$value'),
);

The child it takes is passed back to builder untouched, the standard way of keeping a subtree out of the rebuild.

StateAsNotifier goes the other way: mix it into a State and the state itself becomes a Listenable, with a notifyListeners() for its own use. The ChangeNotifier behind it is created on the first listener and disposed of with the state, so a state nobody listens to costs nothing.

ProgressTracker

Step counting for an initialization that knows how many steps it has:

final tracker = ProgressTracker(3);

tracker.nextStep(); // 1/3
tracker.nextStep(); // 2/3
tracker.addSteps(1); // 3/3
tracker.isCompleted; // true

Progress is the value it produces: number, total, value as a fraction between 0 and 1, and a toString of 2/3. The fraction holds those bounds whatever the pair says: a task of no steps at all reads as complete rather than as the NaN of 0 / 0, so it can go straight into a LinearProgressIndicator. Stepping past the total is a mistake in the caller and is caught by an assertion.

Reporting these from an initialization is the AsyncScope topic; ScopeAutoDependencies uses exactly this to report a step per dependency — see the Scope topic.

ScreenshotReplacer

Renders its child once, captures what was painted, and then shows the image in its place. The package uses it for close(): the last frame of a scope is frozen while the asynchronous disposal runs, so the closing screen is drawn over a still picture rather than over a subtree that is being torn down.

ScreenshotReplacer(
  onCompleted: () => print('captured, or given up on'),
  child: const HomeScreen(),
);

A subtree that is never painted — inside an Offstage, or in the unselected branch of an IndexedStack — cannot be captured. The attempt is therefore bounded by ScreenshotReplacer.maxRetries frames, after which onCompleted is called anyway — and the child is taken away all the same, with nothing in the picture's place. Nothing is reported: that is the case this widget documents as ordinary, and onCompleted together with the absence of a picture is how it is said. Giving up on the picture is not giving up on replacing the child: what waits on onCompleted waits in order to let go of whatever the child holds, and a child left standing gives that caller the report without the thing it was reported for. onCompleted fires exactly once per state, whichever way it ended, including when the widget is removed from the tree first. It fires after the child has been unmounted, not when the replacement is merely scheduled: by then every dispose in the child's subtree has run, so the caller can release what those states were still using.

IsBuildingExtension

IsBuildingExtension extends SchedulerBinding. isBuilding says whether a build is running, and runOutsideBuild(action) runs the action now if it is safe, or in a post-frame callback if one is. This is what a scope uses to keep a notification out of the middle of a build.

A build is not only what a frame runs: runApp builds the first tree with no frame in progress, and marking an element dirty from inside that is refused just the same. isBuilding therefore adds two sources to the frame phase — the rebuilds of this package's own scopes, which it counts itself, and the build owner's flag, which it can only ask behind an assertion because that is the only place the flag exists. So the one case left unanswered is a build that neither belongs to a frame nor to this package, in a release build.

Where to go next

topic what it covers
ScopeNotifier the scope form of select, and the state models
Scope ProgressTracker in the dependency container, and close()
LiteScope close() and the screenshot in their own family

Classes

CompositeListenableSubscription utils
Acts as a container for multiple subscriptions ListenableSubscription that can be canceled at once.
ListenableSelector<L extends Listenable, T extends Object?> utils
A widget that rebuilds when a value selected from a Listenable changes.
ListenableSelectSubscription<T extends Object?> utils
A subscription on selected value changes from a Listenable.
ListenableSubscription utils
A subscription on changes from a Listenable.
Progress utils
ProgressTracker utils
A helper class to track initialization progress as a Progress value.
ScreenshotReplacer utils
A widget that renders its child once, captures a screenshot of it, and then replaces the child with the captured image.

Extensions

IsBuildingExtension on SchedulerBinding utils
ListenableListenExtension on Listenable utils
A helper extension that adds a listen method to Listenable, similar to Stream.listen. It returns a ListenableSubscription that can be easily canceled.
ListenableSelectExtension on L utils
A helper extension that adds a select method to Listenable. It allows listening to a specific value of the Listenable (selector), triggering the listener only when that value changes.