view_model 0.8.0-dev.0
view_model: ^0.8.0-dev.0 copied to clipboard
view model for flutter. Simple and Lightweight & Cross - Widget Sharing & Automatic Resource Disposal
view_model #
The missing ViewModel in Flutter
ChangeLog
English Doc | 中文文档
Thank Miolin for transferring the permission of the view_model package to me.
Quick Start #
- Watch:
watchViewModel<T>()/watchCachedViewModel<T>() - Read:
readViewModel<T>()/readCachedViewModel<T>() - Global:
ViewModel.readCached<T>() / maybeReadCached<T>() - Recycle:
recycleViewModel(vm) - Effects:
listen(onChanged)/listenState/listenStateSelect
Reuse One Instance #
- Key: set
key()in factory → all widgets share the same instance - Tag: set
getTag()→ bind newest instance viawatchCachedViewModel(tag) - Any param: pass any
Objectas key/tag (e.g.'user:$id')
final f = DefaultViewModelFactory<UserViewModel>(
builder: () => UserViewModel(userId: id),
key: 'user:$id',
);
final vm1 = watchViewModel(factory: f);
final vm2 = watchCachedViewModel<UserViewModel>(key: 'user:$id'); // same
Value‑level Rebuilds #
- For fine-grained UI updates, use
ValueNotifierwithValueListenableBuilder. - For more dynamic scenarios,
ObservableValueandObserverBuilderoffer more flexibility. - To rebuild only when a specific value within a
StateViewModelchanges, useStateViewModelValueWatcher.
final title = ValueNotifier('Hello');
ValueListenableBuilder(
valueListenable: title,
builder: (_, v, __) => Text(v),
);
2. Basic Usage #
2.1 Adding Dependencies #
First, add view_model to your project's pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
view_model: ^0.7.0 # Please use the latest version
2.2 Creating a ViewModel #
Inherit ViewModel to define business logic. Treat fields as state and call
notifyListeners() to trigger UI updates.
import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart'; // For debugPrint
class MySimpleViewModel extends ViewModel {
String _message = "Initial Message";
int _counter = 0;
String get message => _message;
int get counter => _counter;
void updateMessage(String newMessage) {
update(() {
_message = newMessage;
});
}
void incrementCounter() {
update(() {
_counter++;
});
}
@override
void dispose() {
// Clean up resources here, such as closing StreamControllers, etc.
debugPrint('MySimpleViewModel disposed');
super.dispose();
}
}
2.3 Creating a ViewModelFactory #
ViewModelFactory is responsible for instantiating ViewModel. Each ViewModel type typically
requires a corresponding Factory.
import 'package:view_model/view_model.dart';
// Assume MySimpleViewModel is defined as above
class MySimpleViewModelFactory with ViewModelFactory<MySimpleViewModel> {
@override
MySimpleViewModel build() {
// Return a new MySimpleViewModel instance
return MySimpleViewModel();
}
}
2.4 Using ViewModel in Widgets #
Mix ViewModelStateMixin into your State and call watchViewModel to bind
and rebuild when notifyListeners() is invoked. Lifecycle is handled for you.
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
// Assume MySimpleViewModel and MySimpleViewModelFactory are defined
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage>
with ViewModelStateMixin<MyPage> {
// 1. Mix in the Mixin
late final MySimpleViewModel simpleVM;
@override
void initState() {
super.initState();
// 2. Use watchViewModel to create and get the ViewModel
// When MyPage is built for the first time, the build() method of MySimpleViewModelFactory will be called to create an instance.
// When MyPage is disposed, if this viewModel has no other listeners, it will also be disposed.
simpleVM =
watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(simpleVM.message)), // Directly access the ViewModel's properties
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Button pressed: ${simpleVM.counter} times'), // Access the ViewModel's properties
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
simpleVM.updateMessage("Message Updated!"); // Call the ViewModel's method
},
child: const Text('Update Message'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => simpleVM.incrementCounter(), // Call the ViewModel's method
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Alternative: ViewModelBuilder (no mixin required)
// Example: Using ViewModelBuilder without mixing ViewModelStateMixin
ViewModelBuilder<MySimpleViewModel>(
factory: MySimpleViewModelFactory(),
builder: (vm) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(vm.message),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => vm.updateMessage("Message Updated!"),
child: const Text('Update Message'),
),
],
);
},
)
Listening to a cached instance: CachedViewModelBuilder
// Example: Using CachedViewModelBuilder to bind to an existing instance
CachedViewModelBuilder<MySimpleViewModel>(
shareKey: "shared-key", // or: tag: "shared-tag"
builder: (vm) {
return Row(
children: [
Expanded(child: Text(vm.message)),
IconButton(
onPressed: () => vm.incrementCounter(),
icon: const Icon(Icons.add),
),
],
);
},
)
2.5 Side‑effects with listeners #
// In the initState of State or another appropriate method
late VoidCallback _disposeViewModelListener;
@override
void initState() {
super.initState();
// Get the ViewModel instance (usually obtained once in initState or via a getter)
final myVm = watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());
_disposeViewModelListener = myVm.listen(onChanged: () {
print('MySimpleViewModel called notifyListeners! Current counter: ${myVm.counter}');
// For example: ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Action performed!')));
});
}
@override
void dispose() {
_disposeViewModelListener(); // Clean up the listener to prevent memory leaks
super.dispose();
}
2.6 Visibility pause/resume #
- Control pause/resume via
viewModelVisibleListenersfromViewModelStateMixin. - Call
onPause()when covered; callonResume()to resume and force a single refresh. Wire toRouteObserveror your visibility mechanism.
class _MyPageState extends State<MyPage> with ViewModelStateMixin<MyPage>, RouteAware {
void didPushNext() {
viewModelVisibleListeners.onPause();
}
void didPopNext() {
viewModelVisibleListeners.onResume(); // triggers one refresh
}
}
3. Detailed Parameter Explanation #
3.1 ViewModelFactory #
Factory creates and identifies instances. Use key() to share one instance,
use getTag() to group/discover.
| Method/Property | Type | Optional | Description |
|---|---|---|---|
build() |
T |
❌ Must implement | The factory method to create a ViewModel instance. Typically, constructor parameters are passed here. |
key() |
Object? |
✅ Optional | Provides a unique identifier for the ViewModel. ViewModels with the same key will be automatically shared (recommended for cross-widget/page sharing). |
getTag() |
Object? |
✅ | Add a tag for ViewModel instance. get tag by viewModel.tag. and it's used by find ViewModel by watchViewModel(tag:tag). |
Note: If you use a custom key object, implement
==andhashCodeto ensure correct cache lookup.
class MyViewModelFactory with ViewModelFactory<MyViewModel> {
// Your custom parameters, usually passed to MyViewModel
final String initialName;
MyViewModelFactory({required this.initialName});
@override
MyViewModel build() {
return MyViewModel(name: initialName);
}
/// The key for sharing the ViewModel. The key is unique, and only one ViewModel instance will be created for the same key.
/// If the key is null, no sharing will occur.
@override
Object? key() => "user-profile";
}
3.3 ViewModel Lifecycle #
watch*/read*bind ViewModel to a State- When no widget watchers remain, the instance auto‑disposes
3.4 ViewModel → ViewModel dependencies #
Inside a ViewModel, use readCachedViewModel (non‑reactive) or
watchCachedViewModel (reactive) to depend on other ViewModels. The host’s
widget State manages the dependency lifecycle for you.
When a ViewModel (the HostVM) accesses another ViewModel (the SubVM) via watchCachedViewModel, the framework automatically binds the SubVM's lifecycle to the HostVM's UI observer (i.e., the State object of the StatefulWidget).
This means both the SubVM and the HostVM are directly managed by the lifecycle of the same State object. When this State object is disposed, if neither the SubVM nor the HostVM has other observers, they will be disposed of together automatically.
This mechanism ensures clear dependency relationships between ViewModels and enables efficient, automatic resource management.
class UserProfileViewModel extends ViewModel {
void loadData() {
// One-time access, no listening
final authVM = readCachedViewModel<AuthViewModel>();
if (authVM?.isLoggedIn == true) {
_fetchProfile(authVM!.userId);
}
}
void setupReactiveAuth() {
// Reactive access - automatically updates when auth changes
final authVM = watchCachedViewModel<AuthViewModel>();
// This ViewModel will be notified when authVM changes
}
void manualListening() {
final authVM = readCachedViewModel<AuthViewModel>();
// You can also manually listen to any ViewModel
authVM?.listen(() {
// Custom listening logic
_handleAuthChange(authVM);
});
}
}
4. Stateful ViewModel (StateViewModel<S>) #
Use StateViewModel<S> when you prefer an immutable state object and
updates via setState(newState). Supports listenState(prev, next) for
state‑specific reactions.
4.1 Defining the State Class #
First, you need to define a state class. It is strongly recommended that this class is immutable,
typically achieved by providing a copyWith method.
// example: lib/my_counter_state.dart
import 'package:flutter/foundation.dart';
@immutable // Recommended to mark as immutable
class MyCounterState {
final int count;
final String statusMessage;
const MyCounterState({this.count = 0, this.statusMessage = "Ready"});
MyCounterState copyWith({int? count, String? statusMessage}) {
return MyCounterState(
count: count ?? this.count,
statusMessage: statusMessage ?? this.statusMessage,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MyCounterState &&
runtimeType == other.runtimeType &&
count == other.count &&
statusMessage == other.statusMessage;
@override
int get hashCode => count.hashCode ^ statusMessage.hashCode;
@override
String toString() => 'MyCounterState{count: $count, statusMessage: $statusMessage}';
}
4.2 Creating a Stateful ViewModel #
Inherit from StateViewModel<S>, where S is the type of the state class you defined.
// example: lib/my_counter_view_model.dart
import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart';
import 'my_counter_state.dart'; // Import the state class
class MyCounterViewModel extends StateViewModel<MyCounterState> {
// The constructor must initialize the state via super
MyCounterViewModel({required MyCounterState initialState}) : super(state: initialState);
void increment() {
// Use setState to update the state, which will automatically handle notifyListeners
setState(state.copyWith(count: state.count + 1, statusMessage: "Incremented"));
}
void decrement() {
if (state.count > 0) {
setState(state.copyWith(count: state.count - 1, statusMessage: "Decremented"));
} else {
setState(state.copyWith(statusMessage: "Cannot decrement below zero"));
}
}
void reset() {
// You can directly replace the old state with a new State instance
setState(const MyCounterState(count: 0, statusMessage: "Reset"));
}
@override
void dispose() {
debugPrint('Disposed MyCounterViewModel with state: $state');
super.dispose();
}
}
In StateViewModel, you update the state by calling setState(newState). This method replaces the
old state with the new one and automatically notifies all listeners.
4.3 Creating a ViewModelFactory #
Create a corresponding Factory for your StateViewModel.
// example: lib/my_counter_view_model_factory.dart
import 'package:view_model/view_model.dart';
import 'my_counter_state.dart';
import 'my_counter_view_model.dart';
class MyCounterViewModelFactory with ViewModelFactory<MyCounterViewModel> {
final int initialCount;
MyCounterViewModelFactory({this.initialCount = 0});
@override
MyCounterViewModel build() {
// Create and return the ViewModel instance in the build method, passing the initial state
return MyCounterViewModel(
initialState: MyCounterState(count: initialCount, statusMessage: "Initialized"));
}
}
4.4 Using Stateful ViewModel in Widgets #
Using a stateful ViewModel in a StatefulWidget is very similar to using a stateless ViewModel,
with the main difference being that you can directly access viewModel.state to obtain the current
state object.
// example: lib/my_counter_page.dart
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
import 'my_counter_view_model.dart';
import 'my_counter_view_model_factory.dart';
// MyCounterState will be referenced internally by MyCounterViewModel
class MyCounterPage extends StatefulWidget {
const MyCounterPage({super.key});
@override
State<MyCounterPage> createState() => _MyCounterPageState();
}
class _MyCounterPageState extends State<MyCounterPage>
with ViewModelStateMixin<MyCounterPage> {
late final MyCounterViewModel counterVM;
@override
void initState() {
super.initState();
counterVM = watchViewModel<MyCounterViewModel>(
factory: MyCounterViewModelFactory(initialCount: 10)); // You can pass an initial value
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Stateful ViewModel Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Count: ${counterVM.state.count}', // Directly access the state's properties
style: Theme
.of(context)
.textTheme
.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Status: ${counterVM.state.statusMessage}', // Access other properties of the state
style: Theme
.of(context)
.textTheme
.titleMedium,
),
],
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () => counterVM.increment(),
tooltip: 'Increment',
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => counterVM.decrement(),
tooltip: 'Decrement',
child: const Icon(Icons.remove),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => counterVM.reset(),
tooltip: 'Reset',
icon: const Icon(Icons.refresh),
label: const Text("Reset"),
),
],
),
);
}
}
5. DefaultViewModelFactory Quick Factory #
5.1 When to Use #
For simple cases, use DefaultViewModelFactory<T> to avoid writing a custom
factory.
5.2 Usage #
final factory = DefaultViewModelFactory<MyViewModel>(
builder: () => MyViewModel(),
isSingleton: true, // optional
);
5.3 Parameters #
builder: Function to create the ViewModel instance.key: Custom key for singleton instance sharing.tag: Custom tag for identifying the ViewModel.isSingleton: Whether to use singleton mode. This is just a convenient way to set a unique key for you. Note that the priority is lower than the key parameter.
5.4 Example #
final factory = DefaultViewModelFactory<CounterViewModel>(
builder: () => CounterViewModel(),
);
final sharedFactory = DefaultViewModelFactory<CounterViewModel>(
builder: () => CounterViewModel(),
key: 'global-counter',
);
6. DevTools Extension #
Enable the DevTools extension for real‑time ViewModel monitoring.
create devtools_options.yaml in root directory of project.
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- view_model: true
