lifecycle 1.0.0-alpha1
lifecycle: ^1.0.0-alpha1 copied to clipboard
Composable app, route, page, and viewport lifecycle for Flutter widgets.
English | 简体中文
lifecycle #
Composable lifecycle state for Flutter widgets.
lifecycle combines app state, Navigator routes, paged containers, and scroll
viewports into one immutable LifecycleSnapshot. A descendant can only be
active when every containing scope is active:
App → Navigator route → Page/Tab → Viewport item → Widget
There is no global observer and no event bus. Each Navigator owns an explicit controller, nested scopes compose automatically, and callbacks receive both the resulting snapshot and the cause of the transition.
Lifecycle model #
The current state is represented by:
LifecycleSnapshot.active(
visibleFraction: 1,
appState: AppLifecycleState.resumed,
)
Phases are detached, hidden, visible, active, and disposed. A single
phase is the single source of truth; attached, visible, and active are
derived getters. A single state change produces events in deterministic order:
created → appeared → activated → deactivated → disappeared → disposed
Only events that apply to a transition are emitted. For example, hiding an
active widget emits deactivated and then disappeared.
LifecycleTransition.cause identifies whether the change came from the app,
a route, a back gesture, page selection, a viewport, the widget tree, or a
custom boundary.
Installation #
dependencies:
lifecycle: ^1.0.0-alpha1
import 'package:lifecycle/lifecycle.dart';
App and Navigator setup #
Create one NavigatorLifecycleController per Navigator. The controller owns
its observer; dispose the controller where you create it.
class AppState extends State<App> {
final navigation = NavigatorLifecycleController();
@override
Widget build(BuildContext context) {
return LifecycleApp(
child: MaterialApp(
navigatorObservers: [navigation.observer],
builder: (context, child) => NavigatorLifecycleScope(
controller: navigation,
child: child!,
),
home: const HomeScreen(),
),
);
}
@override
void dispose() {
navigation.dispose();
super.dispose();
}
}
Use a separate controller, observer, and scope for each nested Navigator. Both
imperative routes and the Navigator pages API are supported. Opaque routes
hide the route below; non-opaque routes such as dialogs keep it visible but
inactive. Interactive back gestures expose the previous route as visible.
Routes can also be inspected or removed through the controller:
final route = navigation.routeNamed('/checkout');
final routes = navigation.routes;
if (route != null) {
final lifecycle = navigation.lifecycleFor(route);
debugPrint('checkout: ${lifecycle?.phase}');
navigation.removeRoute(route);
}
Listen from a widget #
For a stateless subtree, use LifecycleListener:
LifecycleListener(
onTransition: (transition) {
debugPrint('${transition.previous.phase} → ${transition.current.phase}');
},
onEvent: (event, transition) {
debugPrint('${event.name} (${transition.cause.name})');
},
child: const Content(),
)
For a State, use LifecycleStateMixin:
class ArticleState extends State<Article> with LifecycleStateMixin<Article> {
@override
void onLifecycleEvent(
LifecycleEvent event,
LifecycleTransition transition,
) {
if (event == LifecycleEvent.activated) {
refreshArticle();
}
}
@override
Widget build(BuildContext context) => const ArticleView();
}
LifecycleBuilder rebuilds when any snapshot field changes, including
visibleFraction:
LifecycleBuilder(
builder: (context, lifecycle) {
return Text(lifecycle.phase.name);
},
)
Custom boundaries #
Use LifecycleBoundary to compose domain state with the surrounding scope.
It can restrict a descendant, but it cannot make a descendant more active than
its parent.
LifecycleBoundary(
constraint: !panelIsOpen || animation.value <= 0
? const LifecycleConstraint.hidden()
: panelHasFocus
? LifecycleConstraint.active(visibleFraction: animation.value)
: LifecycleConstraint.visible(visibleFraction: animation.value),
child: const Panel(),
)
PageView #
LifecyclePageView owns page scopes and forwards the usual PageView options.
The selected page becomes active only after scrolling settles. During a drag,
visible pages expose a fractional visibility and remain inactive.
final controller = PageController(initialPage: 1);
LifecyclePageView(
controller: controller,
onPageTransition: (index, transition) {
debugPrint('page $index: ${transition.events}');
},
children: const [
FeedPage(key: ValueKey('feed')),
SearchPage(key: ValueKey('search')),
],
)
The builder constructor supports large and reorderable data sets. Supply a stable ID and its reverse lookup when order can change:
LifecyclePageView.builder(
controller: controller,
itemCount: items.length,
pageIdBuilder: (index) => items[index].id,
findPageIndex: (id) => items.indexWhere((item) => item.id == id),
itemBuilder: (context, index) => ItemPage(
key: ValueKey(items[index].id),
item: items[index],
),
)
The widget deliberately does not change Flutter's keep-alive policy. A lazy
page that is disposed emits disposed; wrap page content with
AutomaticKeepAliveClientMixin when its State must survive off-screen.
TabBarView #
LifecycleTabBarView(
controller: tabController,
onTabTransition: (index, transition) {
debugPrint('tab $index: ${transition.events}');
},
children: const [OverviewTab(), ActivityTab()],
)
LifecycleTabBarView follows both animated taps and horizontal swipes. A tab
is active only when the TabController is settled.
ListView, GridView, and CustomScrollView #
Place ViewportLifecycleItem below the nearest Scrollable. Visibility is
calculated from the item's two-dimensional area inside that viewport, so
vertical lists, horizontal lists, grids, and slivers share the same behavior.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ViewportLifecycleItem(
visibleThreshold: 0.25,
activeThreshold: 0.8,
activationPolicy:
ViewportLifecycleActivationPolicy.whenScrollSettles,
onTransition: (transition) {
debugPrint('item $index: ${transition.current.visibleFraction}');
},
child: ItemTile(item: items[index]),
),
)
An item is visible when its fraction reaches visibleThreshold. By default it
can become active only after scrolling settles and its fraction reaches
activeThreshold; use ViewportLifecycleActivationPolicy.immediate to retain
activation while scrolling. If activeThreshold is omitted, it uses the
visible threshold. Reported fractions are stabilized to one-percent increments
by default; set visibleFractionGranularity to zero for exact measurements.
The containing app, route, and page states still apply.
Direct controller use #
Most apps only need widgets, but LifecycleController is public for custom
containers. Child state is composed with its parent, listeners are safe to
add/remove during delivery, and reentrant updates are queued deterministically.
final parent = LifecycleController()..attach();
final child = LifecycleController(
constraint: const LifecycleConstraint.hidden(),
);
void onChildChanged() {
final transition = child.lastTransition!;
debugPrint('${transition.previous.phase} -> ${transition.current.phase}');
}
child
..addListener(onChildChanged)
..attach(parent: parent);
child.updateLocal(
constraint: const LifecycleConstraint.active(),
cause: LifecycleCause.custom,
);
final subtree = LifecycleScope(
controller: child,
child: const CustomContainerContent(),
);
LifecycleController uses the standard ChangeNotifier channel for both
snapshot and transition changes. During a listener callback, value is the
latest snapshot and lastTransition describes the atomic change that produced
it. Remember to call removeListener when the listener can outlive the
controller.
See the example application and the tests for complete runnable compositions.
1.0 migration #
Version 1.0 is a deliberate API redesign. The global
defaultLifecycleObserver, legacy wrapper classes, and dispatch/subscribe
mixins were removed. Replace them with explicit LifecycleApp and
NavigatorLifecycleController setup, then use LifecycleListener,
LifecycleStateMixin, LifecyclePageView, LifecycleTabBarView, and
ViewportLifecycleItem.