scopo 0.10.0 copy "scopo: ^0.10.0" to clipboard
scopo: ^0.10.0 copied to clipboard

A Flutter package for managing scopes and dependency injection within the widget tree

scopo #

pub version license

A Flutter package for managing scopes: dependency injection, state, and lifecycle inside the widget tree. A scope owns its dependencies, initializes them asynchronously, provides them to its subtree, and disposes of them in order — after its child scopes are gone.

Features #

  • Scopes: a widget that owns dependencies and a state and provides both to its descendants.
  • Async initialization: initialization is a Stream. It reports progress, drives the loading and error branches, and is cancelled if the scope leaves the tree before it completes.
  • Ordered disposal: a scope waits for its child scopes to be disposed of before disposing of its own dependencies (waitForChildrenTimeout), and scopeKey makes a re-created scope wait for the previous scope with the same key.
  • Selective rebuilds: select and selectParam subscribe a descendant to a single value; notifyDependents rebuilds only those descendants, never the scope's own subtree. setState is the other half and is untouched: it rebuilds the state's own subtree and reaches no subscriber.
  • Graceful closing: close() freezes the subtree as a screenshot and shows buildOnClosing while the asynchronous disposal is running.
  • Specialized scopes: lightweight variants for widget parameters, plain models, Listenables, and for async work that needs no dependency container.
  • Observable lifecycle: a typed observer is told about every initialization, progress step, failure and teardown.

Installation #

flutter pub add scopo
import 'package:scopo/scopo.dart';

Scope #

Scope is the main building block. It has three parts:

  1. the scope widget (Scope) — its constructor parameters are the scope parameters;
  2. a dependency container (ScopeDependencies) — initialized asynchronously before the state is created;
  3. a state (ScopeState) — the same as State of a StatefulWidget, but with direct access to the dependencies.

1. Dependencies #

Implement ScopeDependencies and initialize it with a stream generator: this is what lets the scope report progress and cancel a half-finished initialization when the widget is removed from the tree.

final class AppDependencies implements ScopeDependencies {
  final SharedPreferences sharedPreferences;

  AppDependencies({required this.sharedPreferences});

  static Stream<ScopeInitState<String, AppDependencies>> init() async* {
    yield ScopeProgress('Initializing storage…');
    final sharedPreferences = await SharedPreferences.getInstance();

    yield ScopeReady(AppDependencies(sharedPreferences: sharedPreferences));
  }

  /// Lets go of whatever cannot wait for the asynchronous teardown. Runs
  /// once and always before `dispose`, whether the scope left the tree or
  /// was closed with `close()`.
  @override
  void onUnmount() {}

  /// Called after the state has been disposed of. May be asynchronous.
  @override
  Future<void> dispose() async {}
}

2. State #

Extend ScopeState. The dependencies are ready by the time initState runs. notifyDependents updates the subscribed descendants without rebuilding the state's own subtree.

The two halves are separate on purpose, and nothing here disables setState — a scope state is an ordinary State and keeps it. Which one to call follows from who has to see the change:

what has to update call
descendants subscribed with select / selectParam notifyDependents()
the widgets the state's own build returns setState()
both both

Bumping a field and calling notifyDependents() alone is the mistake worth naming: the subscribers see the new value and the state's own build does not run, so anything it draws from that field stays as it was.

final class AppState extends ScopeState<App, AppDependencies, AppState> {
  late int _counter;
  int get counter => _counter;

  @override
  void initState() {
    super.initState();
    _counter = dependencies.sharedPreferences.getInt('counter') ?? 0;
  }

  Future<void> increment() async {
    _counter++;
    notifyDependents();
    await dependencies.sharedPreferences.setInt('counter', _counter);
  }

  @override
  Widget build(BuildContext context) => const HomeScreen();
}

3. The scope widget #

Extend Scope and provide initDependencies, createState, and the widgets for the initializing and error branches. wrapState wraps the ready branch only, so widgets shared by all branches (such as MaterialApp) are usually created in each builder.

final class App extends Scope<App, AppDependencies, AppState> {
  final String title;

  const App({super.key, required this.title});

  @override
  Stream<ScopeInitState<String, AppDependencies>> initDependencies(
    BuildContext context,
  ) =>
      AppDependencies.init();

  @override
  AppState createState() => AppState();

  @override
  Widget buildOnProgress(
    BuildContext context,
    covariant String? progress,
  ) =>
      MaterialApp(home: Scaffold(body: Center(child: Text(progress ?? ''))));

  @override
  Widget buildOnError(
    BuildContext context,
    Object error,
    StackTrace stackTrace,
    covariant String? progress,
  ) =>
      MaterialApp(home: Scaffold(body: Center(child: Text('$error'))));

  /// Widgets placed between [App] and [AppState] in the ready branch.
  @override
  Widget wrapState(
    BuildContext context,
    AppDependencies dependencies,
    Widget child,
  ) =>
      MaterialApp(title: title, home: child);

  /// Access helpers for descendants.
  static AppState of(BuildContext context) =>
      Scope.of<App, AppDependencies, AppState>(context);

  static V select<V>(
    BuildContext context,
    V Function(AppState state) selector,
  ) =>
      Scope.select<App, AppDependencies, AppState, V>(context, selector);

  static V selectParam<V>(
    BuildContext context,
    V Function(App widget) selector,
  ) =>
      Scope.selectParam<App, AppDependencies, AppState, V>(context, selector);
}

4. Access from descendants #

Scope.of never subscribes — use it for calling methods. To rebuild on changes, subscribe to a single value with select (state) or selectParam (scope parameters). Scope.paramsOf and Scope.maybeOf are available too.

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Subscribes to a single value: rebuilt only when `counter` changes.
    final counter = App.select(context, (state) => state.counter);

    // Subscribes to a scope parameter.
    final title = App.selectParam(context, (widget) => widget.title);

    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: Center(child: Text('$counter')),
      floatingActionButton: FloatingActionButton(
        // Reads the state without subscribing to it.
        onPressed: () => App.of(context).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

In depth: the topic Scope.

Specialized scopes #

Lightweight alternatives for cases where a full Scope is too much.

ScopeWidgetBase #

Provides the widget's own parameters to its subtree. Descendants subscribe per parameter, so an unrelated parameter change does not rebuild them.

final class ApiConfig extends ScopeWidgetBase<ApiConfig> {
  final String apiKey;

  const ApiConfig({
    super.key,
    required this.apiKey,
    required super.child,
  });

  static String apiKeyOf(BuildContext context) =>
      ScopeWidgetBase.select<ApiConfig, String>(
        context,
        (widget) => widget.apiKey,
      );

  @override
  Widget build(BuildContext context) => child;
}

In depth: the topic ScopeWidget.

ScopeModel #

Owns a plain Dart object: create builds it, dispose releases it. The model is not observable, so descendants are notified when the ScopeModel widget itself is rebuilt, and a selector then filters out the unchanged values. Subclass ScopeModelBase to get a named scope with its own static accessors.

class UserGate extends StatelessWidget {
  const UserGate({super.key});

  @override
  Widget build(BuildContext context) => ScopeModel<UserModel>(
        create: (context) => UserModel('Alice'),
        dispose: (model) => model.dispose(),
        builder: (context) => const UserView(),
      );
}

class UserView extends StatelessWidget {
  const UserView({super.key});

  @override
  Widget build(BuildContext context) {
    // Without a subscription:
    final user = ScopeModel.of<UserModel>(context, listen: false);

    // With a subscription to the selected value:
    final name = ScopeModel.select<UserModel, String>(
      context,
      (model) => model.name,
    );

    return Text('$name (${user.name})');
  }
}

In depth: the topic ScopeModel.

ScopeNotifier #

The same as ScopeModel, but for a Listenable (ChangeNotifier, ValueNotifier, …): the scope subscribes to the model and rebuilds the descendants whose selected value has changed. ScopeNotifierBase is the subclassable variant.

class CounterGate extends StatelessWidget {
  const CounterGate({super.key});

  @override
  Widget build(BuildContext context) => ScopeNotifier<Counter>(
        create: (context) => Counter(),
        dispose: (counter) => counter.dispose(),
        builder: (context) => const CounterText(),
      );
}

class CounterText extends StatelessWidget {
  const CounterText({super.key});

  @override
  Widget build(BuildContext context) {
    // Rebuilt on `notifyListeners`, and only if the selected value changed.
    final value = ScopeNotifier.select<Counter, int>(
      context,
      (counter) => counter.value,
    );

    return TextButton(
      onPressed: ScopeNotifier.of<Counter>(context, listen: false).increment,
      child: Text('$value'),
    );
  }
}

In depth: the topic ScopeNotifier.

AsyncScope #

Async initialization and disposal without a dependency container: use it when the objects are already reachable (a singleton, a repository from a parent scope) and only their lifecycle has to be driven by the tree. Descendants can read the current state with AsyncScope.of(context, listen: …).state.

class ConnectionGate extends StatelessWidget {
  const ConnectionGate({super.key});

  @override
  Widget build(BuildContext context) => AsyncScope(
        initScope: (context) async* {
          yield AsyncScopeProgress('connecting');
          await connection.open();

          yield AsyncScopeReady();
        },
        disposeScope: () => connection.close(),
        progressBuilder: (context, progress) => Text('$progress'),
        errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
        builder: (context) => const HomeScreen(),
      );
}

In depth: the topic AsyncScope.

AsyncDataScope #

AsyncScope plus one value: the data produced by initData is passed to builder and to disposeData. Descendants read it with AsyncDataScope.of<Database>(context, listen: false).data.

class DatabaseGate extends StatelessWidget {
  const DatabaseGate({super.key});

  @override
  Widget build(BuildContext context) => AsyncDataScope<Database>(
        initData: (context) async* {
          yield AsyncDataScopeProgress('opening the database');

          yield AsyncDataScopeReady(await Database.open());
        },
        disposeData: (database) => database.close(),
        progressBuilder: (context, progress) => Text('$progress'),
        errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
        builder: (context, database) => DatabaseView(database: database),
      );
}

In depth: the topic AsyncDataScope.

AsyncControllerScope #

AsyncDataScope whose value is a controller with a lifecycle of its own. The scope creates it, awaits its init, tells it to let go the moment the scope leaves the tree, and awaits its dispose — on every path, including the two where a hand-written version loses it: an init that threw, and an init interrupted before it finished. Reach for it when the scope exists because something has to run while a part of the tree is on screen, rather than because something has to be shown.

final class Player extends AsyncControllerScopeBase<Player, PlayerController> {
  const Player({super.key, required super.child}) : super(scopeKey: Player);

  @override
  PlayerController createController(BuildContext context) =>
      PlayerController(api: ScopeModel.of<Api>(context, listen: false));

  @override
  Widget buildOnProgress(BuildContext context) => const SizedBox.shrink();

  @override
  Widget buildOnError(BuildContext context, Object error, StackTrace stack) =>
      const SizedBox.shrink();

  @override
  Widget buildOnReady(BuildContext context, PlayerController controller) =>
      child;
}

final class PlayerController extends ScopeController {
  final Api api;

  StreamSubscription<Track>? _subscription;

  PlayerController({required this.api});

  /// Awaited before the ready branch is built. `mounted` says whether the
  /// scope is still there after an `await`.
  @override
  Future<void> init() async {
    final session = await api.openSession();
    if (!mounted) return;

    _subscription = session.tracks.listen(_onTrack);
  }

  /// Synchronous, the moment the scope leaves the tree.
  @override
  void onUnmount() => unawaited(_subscription?.cancel());

  /// Awaited, after `onUnmount`.
  @override
  Future<void> dispose() async => api.closeSession();
}

AsyncControllerScope<C> is the same thing with a createController callback instead of a subclass.

In depth: the topic AsyncControllerScope.

LiteScope #

Scope without the dependency container: the state is created without an async dependency phase, and still gets the full scope lifecycle — initStateAsync, disposeStateAsync, notifyDependents, close, scopeKey, and waiting for child scopes. A good fit for per-screen state that owns disposable objects.

final class ScreenScope extends LiteScope<ScreenScope, ScreenScopeState> {
  const ScreenScope({super.key, super.scopeKey});

  /// Shown on the first frames, and while waiting for [scopeKey]. Returning
  /// `null` here requires overriding [buildOnProgress].
  @override
  Widget? buildOnWaiting(BuildContext context) => const SizedBox.shrink();

  @override
  ScreenScopeState createState() => ScreenScopeState();

  static ScreenScopeState of(BuildContext context) =>
      LiteScope.of<ScreenScope, ScreenScopeState>(context);
}

final class ScreenScopeState
    extends LiteScopeState<ScreenScope, ScreenScopeState> {
  final controller = ScrollController();

  /// Awaited before the scope leaves the tree.
  @override
  Future<void> disposeStateAsync() async => controller.dispose();

  @override
  Widget build(BuildContext context) =>
      ListView(controller: controller, children: const [Text('item')]);
}

Every family above is demonstrated side by side, with a live log of each lifecycle call, in the scopo_demo app.

In depth: the topic LiteScope.

scopeKey #

scopeKey serializes scopes that must not overlap: a new scope with the same key waits until the previous one has finished disposing of its dependencies. It requires an AsyncScopeCoordinator above the scopes that use it — the most universal place is above MaterialApp:

AsyncScopeCoordinator(child: MaterialApp(home: HomeScreen()))

Each coordinator scopes scopeKey to its own subtree: two scopes with the same key under different coordinators never wait for one another, and it is always the nearest coordinator above a scope that serves it. The queues belong to the coordinator's element, so serialization holds only for as long as that element does: replacing the coordinator itself — a different ValueKey, a different position in the tree — throws its queues away along with it, which is why it belongs above everything that can be replaced.

A coordinator is also the wait root for the scopes in its subtree that have no scope above them, so AsyncScopeCoordinator.waitForChildren(context) is the way to await those top-level scopes — for example before tearing down a test or finishing a splash screen:

await AsyncScopeCoordinator.waitForChildren(context);

It awaits the scopes registered at the moment of the call, and, like every other wait in the package, it is bounded — by a timeout when one is passed, by ScopeConfig.defaultWaitForChildrenTimeout otherwise. An expiry is not an error the caller has to handle: the future completes normally and the TimeoutException is reported through FlutterError.reportError, unless an onTimeout callback is given.

Observing and configuration #

The package says nothing until ScopeConfig.observer is assigned. A ScopeObserver has one hook per lifecycle event — onInit, onProgress, onReady, onCancelled, onDispose, onDisposed, onError, onTimeout, onTrace — all of them empty, so a subclass overrides only what it needs. ScopePrintObserver comes with the package and writes a line per event.

void main() {
  ScopeConfig.observer = const ScopePrintObserver();

  // How long a scope waits for its `scopeKey` and for its children to be
  // disposed of (3 seconds each by default; `null` means no timeout).
  ScopeConfig.defaultScopeKeyTimeout = const Duration(seconds: 5);
  ScopeConfig.defaultWaitForChildrenTimeout = null;

  runApp(const App(title: 'scopo'));
}

ScopeConfig.pauseAfterInitializationEnabled = false disables the artificial pauseAfterInitialization delays — useful in tests.

Also in the box #

  • NavigationNode — a nested Navigator that keeps dialogs, bottom sheets and pushed screens inside the current scope.
  • ProgressIterator — step counting (1/3, 2/3, …) for initialization progress.
  • ScreenshotReplacer — renders a subtree once, then replaces it with the captured image; used to keep the last frame while a scope is closing. A subtree that is never painted cannot be captured, so the attempt is bounded by ScreenshotReplacer.maxRetries, after which the subtree is taken away with nothing in the picture's place — waiting for the screenshot is how the scope waits to let go of what that subtree holds, so leaving it standing would defeat the wait.

One node per tab: NavigationNode(enabled:) #

A node takes part in the system back of the route it stands on. That is the whole point of it — a back press closes what the node has open before it touches the route around it — and it is fine as long as one node stands on a route.

Tabs break that assumption. The usual shape keeps a node per tab in an IndexedStack, which builds every branch and shows one, so all of them are on the route at once. A route asks each of its PopEntrys and calls each of them back, so one back press unwound the stack of every tab, the hidden ones included: you pressed back on tab B and tab A quietly lost a screen.

The node cannot work out which of them is the one on screen. A hidden branch of an IndexedStack — and an Offstage subtree — answers TickerMode.of(context) and ModalRoute.of(context) exactly as a shown one does, and the order sibling nodes register in says nothing about which is visible. The application knows, and enabled is where it says so:

IndexedStack(
  index: _tab,
  children: [
    for (var i = 0; i < tabs.length; i++)
      NavigationNode(
        enabled: i == _tab,
        child: tabs[i],
      ),
  ],
)

A disabled node takes no place on the route: it is not asked and it is not called back. Everything else goes on working — its nested navigator keeps its stack, and Navigator.of(context) from inside still pushes and pops there, so switching back to the tab finds it where it was left.

Nodes nested one inside another never need this: an inner node registers on the page of the navigator above it rather than on the route both stand on, so two of them are never asked about the same press.

The ambiguity is Flutter's own — two PopScopes on one route are both consulted — and an application resolves it the same way.

Examples #

  • minimal — one scope, SharedPreferences initialized asynchronously, loading and error screens, a counter.
  • scopo_demo — a demo of every scope family with a console showing the lifecycle events, plus nested scopes, scopeKey, deferred closing, and navigation nodes: ten tabs.
  • navigation_nodeNavigationNode on its own, in six lessons: nested navigators, dialogs that belong to the screen, onPop, isRoot, and a system back you can press on a desktop, with a journal showing what answered each press.

Documentation #

One topic per family, each covering what the API reference cannot: the order things happen in, the trade-offs, and the traps. The links below go to the repository, where these pages live as doc/*.md; the same pages are rendered on pub.dev as Topics, inside the API reference above.

topic start here for
base of, select, listen, and how a scope is found at all
ScopeWidget the widget/element pair every family extends
ScopeModel owning a plain object
ScopeNotifier owning a Listenable
AsyncScope the asynchronous lifecycle, scopeKey, the coordinator
AsyncDataScope the same, producing a value
AsyncControllerScope the same, where that value is a controller with a lifecycle of its own
LiteScope a state class with that lifecycle, and close()
Scope the full family: dependencies, state, four branches
debug the observer, timeouts, and the test setup
utils the helpers that come with the package
1
likes
0
points
389
downloads

Publisher

verified publisheryet-another.dev

Weekly Downloads

A Flutter package for managing scopes and dependency injection within the widget tree

Repository (GitHub)
View/report issues

Topics

#state-management #dependency-injection #scope #widget

License

unknown (license)

Dependencies

flutter

More

Packages that depend on scopo