fl_mvvm 2.0.0-dev-3 fl_mvvm: ^2.0.0-dev-3 copied to clipboard
A concise and powerful Flutter package that implements the MVVM (Model-View-ViewModel) architecture. Built on top of Signals for state management, this package provides a seamless and efficient way to [...]
A concise and powerful Flutter package that implements the MVVM (Model-View-ViewModel) architecture. Built on top of Signals for state management, this package provides a seamless and efficient way to handle the state of the view in your Flutter applications. With a clean separation of concerns and a reactive approach, it simplifies the development process and enhances the maintainability of your codebase. Empower your Flutter projects with the MVVM architecture and leverage the capabilities of Signals for efficient state management.
Features ๐ฏ #
-
๐๏ธ View and View Model Creation: Easily create Views and View Models for your application.
-
๐ ๏ธ State Handling: Built-in support for various predefined states such as Data State, Loading State, Error State and Empty State.
-
๐จ Custom State Support: Define and integrate custom states tailored to the application's needs.
-
๐ State-Driven UI: Views react dynamically to state changes, with each state managed separately in dedicated build methods.
-
๐ฆ Custom Container Support: Include custom containers that remain unaffected by state changes, providing consistent UI elements across different states.
-
๐งฉ Flexible UI Behavior: Override methods to customize UI behavior for each state, ensuring a seamless user experience.
-
๐ Dependency Management: View Models can depend on other View Model states through reactive signals, enabling reactive state management throughout the application.
-
โ๏ธ Reactive State Management: Utilize reactive programming paradigms to manage state efficiently across the entire application.
Usage ๐จ #
The ViewModel #
Create a ViewModel ๐๏ธ
To create a ViewModel, simply create a class that extends the FlViewModel
class
class MyViewModel extends FlViewModel {}
Using the state ๐พ
The state of each ViewModel is accessible using the getter value
MyViewModel viewModel = MyViewModel();
dynamic state = viewModel.value;
As you can see the type of the state is dynamic
but you can set the type to whatever you want using a generic type
MyViewModel viewModel = MyViewModel<String>();
String? state = viewModel.value;
By default, the state of the view model will be an Empty State, and the value of an empty state will be null
.
onViewInitialized ๐ฑ
onViewInitialized()
is a useful method that allows you to run code typically for initializing parameters, immediately after the View is initialized
class MyViewModel extends FlViewModel {
@override
void onViewInitialized() {
// This code will run only once when the associated View is initialized.
}
}
๐ This method is executed only once when the first view using this view model is initialized.
onDispose โป๏ธ
onDispose()
is a useful method that you can use to clean things up. For example, you can release some resources like listeners, streams or files.
๐ก This method will be called when the view model is getting disposed of
class MyViewModel extends FlViewModel {
@override
void onDispose() {
// This code will run when the view model is getting disposed of
// You might want to release resources, dispose of a disposable state, stop a timer, etc...
}
}
Auto Dispose ๐ค
Setting autoDispose
to true will automatically mark the view model as disposed of when there are no more state listeners (Either Views or ViewModels).
class MyViewModel extends FlViewModel{
MyViewModel({super.autoDispose});
}
MyViewModel vm = MyViewModel(autoDispose:true);
By default, autoDispose
is set to false.
โ ๏ธ Do not set
autoDispose
to true if other views or view models might depend on the state of your view model in the future
Loading State โณ
To display the Loading state you can use the built-in setLoading()
method:
class MyViewModel extends FlViewModel {
void loadUsers() async {
setLoading(); // This will cause the View to render the loading UI state
/*...Continue loading users...*/
}
}
By calling setLoading()
, you trigger the View to render the loading UI state, indicating that data is being fetched or processed.
Data State ๐
To display the Data state you can use the built-in setData()
method:
class CounterViewModel extends FlViewModel<int> {
void incrementCounter() {
int currentValue = value ?? 0;
int newValue = currentValue + 1;
setData(newValue); // This will cause the view to render the new Data
}
}
By calling setData(newValue)
, you update the ViewModel state with the new value, which
triggers the View to render the updated Data state.
Future Data โญ
If you need to set the data state from a Future
, you can use the built-in setFutureData()
method, which accepts your future as a parameter:
// Suppose that we have a products repository defined like this:
class ProductsRepository {
static Future<List<Product>> getAllProducts() async {
// call api
List<Product> products = await Api().getProducts();
return products;
}
}
class ProductsViewModel extends FlViewModel<List<Product>> {
void loadProducts() {
setFutureData(
ProductsRepository.getAllProducts(),
withLoading: false,
); // This will set the state to loading, asynchronously load the list of products, update the state, and trigger the View to build the Data state
}
}
The setFutureData()
method sets the state to Loading just before executing the Future
. If you
don't want to change the state to Loading while fetching the data, you can set the withLoading
parameter to false
:
void loadProducts() {
setFutureData(
ProductsRepository.getAllProducts(),
withLoading: false,
); // This will asynchronously load the list of products, update the state, and trigger the View to build the Data state
}
If the Future
throws an Error, setFutureData()
will catch it and change the state to
the Error state.
Empty State ๐ซ
You can use the built-in setEmpty()
method to clear the current state:
class MyViewModel extends FlViewModel<List<int>> {
void clear() {
setEmpty(); // This will cause the view to render the Empty UI state
}
}
By calling setEmpty()
, you clear the current state of the ViewModel, which triggers the **
View** to
render the Empty UI state. This is useful when you want to indicate that there is no data to display.
Error State โ
To display the Error state you can use the built-in setError()
method:
class CounterViewModel extends FlViewModel<int> {
void incrementCounter() {
int currentValue = value ?? 0;
int newValue = currentValue + 1;
if (newValue > 10) {
setError(Exception("You have reached the limits ๐ฎ"));
} else{
setData(newValue);
}
}
}
By calling setError(Exception("You have reached the limits ๐ฎ"))
, you set the state to the Error
state, which triggers the View to render the corresponding UI for handling errors.
You can also set the Error state in a catch
block:
class CounterViewModel extends FlViewModel<int> {
void incrementCounter() {
try {
int currentValue = value ?? 0;
int newValue = currentValue + 1;
if (newValue > 10) {
throw Exception("You have reached the limits ๐ฎ");
}
setData(newValue);
} catch (error, stack) {
setError(error, stack);
}
}
}
In the catch
block, you catch the error and set it using setError(error, stack)
, which triggers
the View to render the Error UI state.
Custom State ๐ฆ
If you want to create a custom state simply implement the FlState interface
class CustomState implements FlState{
// implementation ...
}
then use viewModel.setState()
to use your custom state.
For example, you might want to customize how the data is displayed:
enum DisplayMode{
list,
grid
}
class MyCustomState<T> implements FlState<T> {
const MyCustomState({required this.mode, this.data});
final DisplayMode mode;
@override
final T? data;
}
myViewModel.setState(MyCustomLoadingState(mode: DisplayMode.list, data: [/*Lorem, Ipsum*/]));
Depending on other view models โ๏ธ
Sometimes your view model requires the state of another view model and needs to rebuild on state change.
Let's say you have a ProductsViewModel
and a FavoritesViewModel
. The FavoritesViewModel
must filter favorites out of the products in the ProductsViewModel
. To do so, simply override the buildDependencies()
method.
final getIt = GetIt.instance;
void main(){
getIt.registerSingleton<ProductsViewModel>(ProductsViewModel());
}
/// Holds the list of available products
class ProductsViewModel extends FlViewModel<List<Product>>{
}
class FavoriteProductsViewModel extends FlViewModel<List<Product>>{
@override
buildDependencies(){
// Accessing ProductsViewModel.value will make this instance to automatically subscribe to the state of the ProductsViewModel instance
// In other words, when the state of ProductsViewModel updates
// This method will get called again
var favorites = getIt.get<ProductsViewModel>().value?.where((e)=>e.isFavorie);
setData(favorites);
}
}
If you access another view model's value inside the buildDependencies
method, the current view model will subscribe to the state of the other view model and whenever the state of the other view model (in our example the ProductsViewModel
) the buildDependencies
will get called again.
In the case of the products/favorites, this can help manage the state across the app, since whenever the product list changes the favorites get updated right away.
The View ๐ #
Create a View ๐๏ธ
To create a View, simply create a new class that extends the FlView
class
class MyView extends FlView {}
The view is a widget and can be part of your flutter Layout just like any other widget
class MyView extends FlView {
const MyView({super.key}); // it is recommended to create a const constructor for your view
}
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(
children: [
Text("Hello world ๐, what a wonderful view ๐!"),
SizedBox(height: 24),
MyView() // render your view
],
),
);
}
}
Link a View to a ViewModel ๐
When you create a View, you will be asked to override the createViewModel()
method and return an object of type FlViewModel
class MyViewModel extends FlViewModel {}
class MyView extends FlView {
@override
FlViewModel createViewModel() {
return MyViewModel();
}
}
If you want your View to be linked to a specific type of ViewModel you can use the generic type just like this:
class MyViewModel extends FlViewModel {}
class MyView extends FlView<MyViewModel> {
@override
MyViewModel createViewModel() {
return MyViewModel();
}
}
๐ The returned ViewModel instance will be linked to this View throughout the lifecycle of the View and cannot be changed.
Alternatively, you can pass a view model through the constructor:
class MyView extends FlView<MyViewModel>{
const MyView({super.viewModel,});
}
MyView(viewModel: someViewModel);
Keep the view model alive ๐
In some cases, a View Model can act as a state manager and other views and view models depend on its state.
Suppose we want to have a view model that holds a list of products, and a view to display the loaded products. But this time we will have another view that displays our favorite products.
To do so, we must keep one view model that holds all the available products and inject it into memory using a service locator like get_it.
The ProductsListView
will depend on the state of the ProductsViewModel
and so is the FavoriteProductsViewModel
.
By default, when a view is being disposed of it will attempt to dispose the view model as well. But In our case, we don't want the ProductsViewModel
to get disposed of due to other parts of our application relying on its state.
That's when keepViewModelAlive
comes in handy, when set to true, the view won't attempt to dispose of the view model with it.
This way even if the ProductsListView
gets disposed of, the ProductsViewModel
will stay alive, and its state will be usable.
You can set KeepViewModelAlive simply by using the super constructor like this:
class MyView extends FlView{
const MyView({super.key}):super(keepViewModelAlive:true);
}
Example:
final getIt = GetIt.instance;
void main(){
getIt.registerSingleton<ProductsViewModel>(ProductsViewModel());
}
/// Holds the list of available products
class ProductsViewModel extends FlViewModel<List<Product>>{
}
class ProductsListView extends FlView<ProductsViewModel>{
const ProductsListView():super(keepViewModelAlive:true);
@override
ProductsViewModel createViewModel(){
return getIt.get<ProductsViewModel>();
}
}
class FavoriteProductsViewModel extends FlViewModel<List<Product>>{
@override
buildDependencies(){
var favorites = getIt.get<ProductsViewModel>().value?.where((e)=>e.isFavorie);
setData(favorites);
}
}
class FavoriteProductsView extends FlView<FavoriteProductsViewModel>{
@override
FavoriteProductsViewModel createViewModel(){
return FavoriteProductsViewModel();
}
}
The UI states of a view ๐จ
The View can handle 4 states of UI
- Data
- Empty
- Loading
- Error
Each UI state is handled by a method that returns a Widget
that you can customize by overriding
it.
The FlView
class provides a default Widget
for all the states except for the Data state, which
you are obliged to implement when you extend the FlView
class
class Model {
final String data;
const Model({this.data="abc"});
}
class MyViewModel extends FlViewModel<Model> {
}
class MyView extends FlView<MyViewModel> {
const MyView({super.key});
@override
Widget buildDataState(BuildContext context, MyViewModel viewModel) {
return Text("Hello ๐ I'm a friendly view, checkout my data => ${viewModel.value?.data}");
// output: Hello ๐ I'm a friendly view, checkout my data => abc
}
@override
MyViewModel createViewModel() {
return MyViewModel();
}
}
Feel free to override the remaining UI states to fit your needs
class MyView extends FlView<MyViewModel> {
const MyView({super.key});
@override
Widget buildDataState(BuildContext context, MyViewModel viewModel) {
return Text("Hello ๐ I'm a friendly view, checkout my data => ${viewModel.value?.data}");
// output: Hello ๐ I'm a friendly view, checkout my data => abc
}
@override
Widget buildErrorState(BuildContext context, MyViewModel viewModel, String error) {
return Text("๐ $error");
}
@override
Widget buildEmptyState(BuildContext context, MyViewModel viewModel) {
return Text("๐ There's no data");
}
@override
Widget buildLoadingState(BuildContext context, MyViewModel viewModel) {
return Text("โฐ Please wait while the view is loading data...");
}
@override
MyViewModel createViewModel() {
return MyViewModel();
}
}
If you want to handle the UI of custom states, just override the buildCustomState
method:
@override
Widget buildCustomState(
BuildContext context, FlState state, MyViewModel viewModel) {
return const Text('Custom State');
}
Wrapping the View with a Container ๐ฆ
Sometimes, you might need to wrap your View inside a container, you can do it the traditional way by wrapping the whole View widget with another widget that acts as a container
Container(child: MyView())
Or you can override the buildContainer()
method to use an inner container that will wrap the rendered UI states while giving you access to the FlViewModel
instance
class Model {
final String data;
const Model({this.data="abc"});
}
class MyViewModel extends FlViewModel<Model> {
}
class MyView extends FlView<MyViewModel> {
const MyView({super.key});
@override
Widget? buildContainer(BuildContext context,
Widget child,
MyViewModel viewModel,) {
// Using a Scaffold as an inner view wrapper
return Scaffold(
// Notice that the view model is accessible from the container making it possible for you to use the state in the container
appBar: AppBar(
title: Text("The data inside the model is ${viewModel.value?.data}"),
),
body: child,
);
}
@override
Widget buildDataState(BuildContext context, MyViewModel viewModel) {
return Text("Hello ๐ I'm a friendly view, checkout my data => ${viewModel.value?.data}");
// output: Hello ๐ I'm a friendly view, checkout my data => abc
}
@override
MyViewModel createViewModel() {
return MyViewModel();
}
}
Testing ๐งช #
You can test the view like any other widget, and if you want to inject a mock view model you can do so by using the viewModel parameter in the constructor of the view.
class MyView extends FlView{
const MyView({super.key, super.viewModel});
}
/**
.
.
.
*/
await tester.pumpWidget(MaterialApp(
home: MyView(
viewModel: mockViewModel,
),
));
On the other hand, view models have a useful method that you can use in your unit tests, which is waitForStateChange
.
This method will allow you to asynchronously wait for the state to change
test('Test data reloaded', () async {
// Arrange
ExampleViewModel viewModel = ExampleViewModel(numberOfItems: 3);
// Act
viewModel.reload();
await viewModel.waitForStateChange<FlDataState>(timeout: 1000);
// Assert
expect(viewModel.value?.length, equals(3));
});
๐ PS: You can find a full example in the /example
folder.
Additional information โน๏ธ #
Thank you for using this package! Your feedback and contribution are greatly appreciated. As a single developer, I have created this package with the intention of assisting you in building reactive views using the MVVM pattern and Signals. While I have put in my best effort, please note that this package may not be perfect or suitable for all types of projects. If you encounter any issues or have suggestions for improvement, please don't hesitate to file an issue on the Github repository. Additionally, contributions in the form of pull requests are always welcome! Your involvement can help enhance the package and make it even more valuable for the community. Thank you for your support and understanding as we work together to create better and more reactive applications.