Aller au contenu principal

Qu'est-ce qu'un ViewModel ?

Comme nous l'avons vu dans les chapitres précédents, un ViewModel est simplement une classe Dart qui étend ChangeNotifier et est responsable de la logique de présentation de la View, de la gestion de son état et de l'exécution d'actions pour les utilisateurs lors de leur interaction avec la View.

BaseViewModel

C'est le ViewModel "par défaut" de l'architecture Stacked avec un état occupé (busy state) et de l'état d'erreur (error state). Il permet aussi de définir cet état basé sur un objet qui lui est transmis, en général une propriété du ViewModel qui l'étend. Cela permet d'avoir des busy states différents pour plusieurs valeurs dans les mêmes ViewModels sans dépendre de valeurs d'état implicites. Il contient également une fonction helper pour indiquer l'état occupé pendant qu'une Future est en cours d'exécution. De cette façon, nous évitons d'avoir à appeler setBusy avant et après chaque appel de Future.

Pour utiliser le BaseViewModel, vous pouvez l'étendre et utiliser la fonctionnalité busy comme suit.

class WidgetOneViewModel extends BaseViewModel {
Human _currentHuman;
Human get currentHuman => _currentHuman;

void setBusyOnProperty() {
setBusyForObject(_currentHuman, true);
// Récupérer des information à jour
setBusyForObject(_currentHuman, false);
}

void setModelBusy() {
setBusy(true);
// Faire quelque chose ici
setBusy(false);
}

Future longUpdateStuff() async {
// Passe busy à true avant de commencer la Future et le repasse à false après son exécution
// Vous pouvez également passer un objet en tant qu'objet busy. Si rien n'est précisé, le ViewModel est utilisé.
var result = await runBusyFuture(updateStuff());
}

Future updateStuff() {
return Future.delayed(const Duration(seconds: 3));
}
}

Cela permet une utilisation pratique dans l'interface utilisateur de manière plus lisible.

class WidgetOneView extends StackedView<WidgetOneViewModel> {
const WidgetOneView({Key? key}) : super(key: key);


Widget builder(
BuildContext context,
WidgetOneViewModel viewModel,
Widget? child,
) {
return GestureDetector(
onTap: () => viewModel.longUpdateStuff(),
child: Container(
width: 100,
height: 100,
// Utilise isBusy pour vérifier si le ViewModel est busy
color: viewModel.isBusy ? Colors.green : Colors.red,
alignment: Alignment.center,
// Un peu idiot de passer la même propriété au viewModel
// mais cela fait sens ici
child: viewModel.busy(viewModel.currentHuman)
? Center(
child: CircularProgressIndicator(),
)
: Container(/* Human Details styling */)
),
),
);
}
}

Les principales fonctionnalités du BaseViewModel sont présentées ci-dessus.

Gestion de l'état busy

Stacked facilite l'indication à la UI si votre ViewModel est occupé ou non en fournissant quelques fonctions utilitaires. Jetons un coup d'œil à un exemple. Lorsque vous exécutez une Future et que vous voulez indiquer à l'interface utilisateur que le ViewModel est occupé, vous utiliseriez le runBusyFuture.

class BusyExampleViewModel extends BaseViewModel {
Future longUpdateStuff() async {
// Passe busy à true avant de commencer la Future et le repasse à false après son exécution
// Vous pouvez également passer un objet en tant qu'objet busy. Si rien n'est précisé, le ViewModel est utilisé.
var result = await runBusyFuture(updateStuff());
}

Future updateStuff() {
return Future.delayed(const Duration(seconds: 3));
}
}

Cela définira la propriété busy en utilisant this comme clé afin que vous puissiez vérifier si la Future est toujours en cours d'exécution en appelant isBusy sur le ViewModel. Si vous voulez lui attribuer une clé différente, dans l'exemple d'une CartView où vous avez plusieurs articles répertoriés. En augmentant la quantité d'un article, vous voulez que seul cet article montre un indicateur occupé. Pour cela, vous pouvez également fournir une clé à la fonction runBusyFuture.

const String BusyObjectKey = 'my-busy-key';

class BusyExampleViewModel extends BaseViewModel {
Future longUpdateStuff() async {
// Passe busy à true avant de commencer la Future et le repasse à false après son exécution
// Vous pouvez également passer un objet en tant qu'objet busy. Si rien n'est précisé, le ViewModel est utilisé.
var result = await runBusyFuture(updateStuff(), busyObject: BusyObjectKey);
}

Future updateStuff() {
return Future.delayed(const Duration(seconds: 3));
}
}

Ensuite, vous pouvez vérifier l'état busy en utilisant cette clé occupée et en appelant viewModel.busy(BusyObjectKey). La clé doit être une valeur unique qui ne changera pas selon l'état busy ou non de l'objet. Dans l'exemple mentionné ci-dessus, vous pouvez utiliser l'identifiant de chacun des produits du panier pour indiquer s'il est occupé ou non. De cette manière, vous pouvez afficher un état occupé pour chacun d'eux individuellement.

Gestion des erreurs

De la même manière que l'état busy est défini, vous obtenez également un état d'erreur. Lorsque vous utilisez l'un des ViewModels spécialisés ou les fonctions utilitaires Future. runBusyFuture ou runErrorFuture de Stacked stockera l'exception levée dans le ViewModel pour que vous puissiez l'utiliser. Il suivra les mêmes règles que le busy ci-dessus et attribuera l'exception au ViewModel ou à la clé transmise. Jetons un coup d'œil à du code.

class ErrorExampleViewModel extends BaseViewModel {
Future longUpdateStuff() async {
// Passe busy à true avant de commencer la Future et le repasse à false après son exécution
// Vous pouvez également passer un objet en tant qu'objet busy. Si rien n'est précisé, le ViewModel est utilisé.
var result = await runBusyFuture(updateStuff());
}

Future updateStuff() async {
await Future.delayed(const Duration(seconds: 3));
throw Exception('Things went wrong');
}
}

Après 3 secondes, cette Future lancera une erreur. Il la capturera automatiquement, rétablira l'affichage en occupé, puis enregistrera l'erreur. Lorsqu'aucune clé n'est fournie à runBusyFuture, vous pouvez vérifier s'il y a une erreur en utilisant la propriété hasError. Vous pouvez également obtenir l'exception réelle à partir de la propriété modelError. Si vous fournissez une clé, vous pouvez obtenir l'exception en utilisant la fonction d'erreur.

const String BusyObjectKey = 'my-busy-key';

class BusyExampleViewModel extends BaseViewModel {
Future longUpdateStuff() async {
// Passe busy à true avant de commencer la Future et le repasse à false après son exécution
// Vous pouvez également passer un objet en tant qu'objet busy. Si rien n'est précisé, le ViewModel est utilisé.
var result = await runBusyFuture(updateStuff(), busyObject: BusyObjectKey);
}

Future updateStuff() {
return Future.delayed(const Duration(seconds: 3));
}
}

Dans ce cas, l'erreur peut être récupérée en utilisant viewModel.error(BusyObjectKey) ou vous pouvez simplement vérifier s'il y a une erreur pour la clé en utilisant viewModel.hasErrorForKey(BusyObjectKey). Si vous souhaitez réagir à une erreur de votre Future, vous pouvez overrider onFutureError qui renverra l'exception et la clé que vous avez utilisée pour cette Future. Les ViewModels spécialisés ont leur propre override d'onError, mais celle-ci peut également être utilisée là si nécessaire.

ViewModels spécialisés

En plus du BaseViewModel, Stacked inclut plusieurs ViewModel spécialisés qui réduisent le code redondant nécessaire pour les cas d'utilisation les plus courants. Ceux-ci sont décrits ci-dessous.

ReactiveViewModel

Ce ViewModel étend le BaseViewModel et ajoute une fonction qui vous permet d'écouter les services utilisés dans le ViewModel. Il y a deux choses à faire pour qu'un ViewModel réagisse aux modifications d'un service.

  1. Étendre ReactiveViewModel.
  2. Implémenter le getter listenableServices qui renvoie une liste de services observables.
class AnyViewModel extends ReactiveViewModel {
final _postsService = locator<PostsService>();

int get postCount => _postsService.postCount;


List<ListenableServiceMixin> get listenableServices => [_postsService];
}

Du côté du service, le service doit utiliser le ListenableServiceMixin et passer à listenToReactiveValues les propriétés à observer.

class PostService with ListenableServiceMixin {
PostService() {
listenToReactiveValues([_postCount]);
}

int _postCount = 0;
int get postCount => _postCount;

Future<void> increment() async {
_postCount++;
notifyListeners(); // Les ViewModels observant la valeur de postCount sont notifiés et leur View est reconstruite
}
}

StreamViewModel

Ce ViewModel étend le BaseViewModel et offre une fonctionnalité pour observer et réagir facilement aux streams de données. Il vous permet de fournir un Stream de type T auquel il s'abonnera, gérera l'abonnement (disposera lorsque c'est fait) et vous donnera des callbacks où vous pouvez modifier/manipuler les données. Il reconstruira automatiquement la View à mesure que de nouvelles valeurs du Stream arriveront. Il a 1 override requis qui est le getter du Stream et 4 overrides facultatifs.

  • stream: Retourne le stream que vous souhaitez écouter.
  • onData: Appelé après que la vue ait été reconstruite et vous fournit les données à utiliser.
  • onCancel: Appelé après que le stream ait été disposé.
  • onSubscribed: Appelé lorsque le stream a été souscrit.
  • onError: Appelé lorsqu'une erreur est envoyée sur le stream.
// ViewModel
class StreamCounterViewModel extends StreamViewModel<int> {

String get title => 'This is the time since epoch in seconds \n $data';


Stream<int> get stream => locator<EpochService>().epochUpdatesNumbers();
}

// View
class StreamCounterView extends StatelessWidget {

Widget build(BuildContext context) {
return ViewModelBuilder<StreamCounterViewModel>.reactive(
builder: (context, viewModel, child) => Scaffold(
body: Center(
child: Text(viewModel.title),
),
),
viewModelBuilder: () => StreamCounterViewModel(),
);
}
}

// Service (enregistré en utilisant l'injection, NON OBLIGATOIRE)

class EpochService {
Stream<int> epochUpdatesNumbers() async* {
while (true) {
await Future.delayed(const Duration(seconds: 2));
yield DateTime.now().millisecondsSinceEpoch;
}
}
}

Le code ci-dessus écoutera un stream et vous fournira les données avec lesquelles reconstruire. Vous pouvez créer un ViewModel qui écoute un stream avec deux lignes de code.

class StreamCounterViewModel extends StreamViewModel<int> {

Stream<int> get stream => locator<EpochService>().epochUpdatesNumbers();
}

En plus de la fonction onError, vous pouvez remplacer le ViewModel qui passera également la propriété hasError à true pour faciliter la vérification côté View. Le rappel onError peut être utilisé pour exécuter des actions supplémentaires en cas d'échec, et la propriété hasError doit être utilisée lorsque vous souhaitez afficher une interface utilisateur spécifique à l'erreur.

FutureViewModel

Ce ViewModel étend le BaseViewModel pour fournir une fonctionnalité pour écouter facilement une Future qui récupère des données. Cette exigence découle d'une "View détails" qui doit récupérer des données supplémentaires à afficher à l'utilisateur après avoir sélectionné un élément dans une liste par exemple. Lorsque vous étendez le FutureViewModel, vous pouvez fournir un type qui vous obligera ensuite à overrider le getter de la Future que vous voulez exécuter.

La Future s'exécutera automatiquement après la création du ViewModel.

class FutureExampleViewModel extends FutureViewModel<String> {

Future<String> futureToRun() => getDataFromServer();

Future<String> getDataFromServer() async {
await Future.delayed(const Duration(seconds: 3));
return 'This is fetched from everywhere';
}
}

Cela définira automatiquement la propriété isBusy de la View et indiquera false lorsqu'elle est complète. Il expose également une propriété dataReady qui peut être utilisée. Cela indiquera true lorsque les données sont disponibles. Le ViewModel peut être utilisé dans une vue comme suit.

class FutureExampleView extends StatelessWidget {

Widget build(BuildContext context) {
return ViewModelBuilder<FutureExampleViewModel>.reactive(
builder: (context, viewModel, child) => Scaffold(
body: Center(
// Le viewModel va indiquier busy jusqu'à ce que la Future soit terminée
child: viewModel.isBusy ? CircularProgressIndicator() : Text(viewModel.data),
),
),
viewModelBuilder: () => FutureExampleViewModel(),
);
}
}

Le FutureViewModel capturera également une erreur et indiquera qu'il a reçu une erreur via la propriété hasError. Vous pouvez également overrider la fonction onError si vous voulez recevoir cette erreur et effectuer une action spécifique à ce moment-là.

class FutureExampleViewModel extends FutureViewModel<String> {

Future<String> get future => getDataFromServer();

Future<String> getDataFromServer() async {
await Future.delayed(const Duration(seconds: 3));
throw Exception('This is an error');
}


void onError(error) {
}
}

La propriété hasError peut être utilisée de la même manière que la propriété isBusy.

class FutureExampleView extends StatelessWidget {

Widget build(BuildContext context) {
return ViewModelBuilder<FutureExampleViewModel>.reactive(
builder: (context, viewModel, child) => Scaffold(
body: viewModel.hasError
? Container(
color: Colors.red,
alignment: Alignment.center,
child: Text(
'An error has occered while running the future',
style: TextStyle(color: Colors.white),
),
)
: Center(
child: viewModel.isBusy
? CircularProgressIndicator()
: Text(viewModel.data),
),
),
viewModelBuilder: () => FutureExampleViewModel(),
);
}
}

MultipleFutureViewModel

En plus de pouvoir exécuter une Future, vous pouvez également faire en sorte qu'une vue réagisse aux données renvoyées par plusieurs Futures. Cela vous oblige à fournir une map de string ainsi qu'une fonction qui retourne une Future et qui sera exécutée après que le ViewModel soit construit. Ci-dessous, un exemple d'utilisation d'un MultipleFutureViewModel.

import 'package:stacked/stacked.dart';

const String _NumberDelayFuture = 'delayedNumber';
const String _StringDelayFuture = 'delayedString';

class MultipleFuturesExampleViewModel extends MultipleFutureViewModel {
int get fetchedNumber => dataMap[_NumberDelayFuture];
String get fetchedString => dataMap[_StringDelayFuture];

bool get fetchingNumber => busy(_NumberDelayFuture);
bool get fetchingString => busy(_StringDelayFuture);


Map<String, Future Function()> get futuresMap => {
_NumberDelayFuture: getNumberAfterDelay,
_StringDelayFuture: getStringAfterDelay,
};

Future<int> getNumberAfterDelay() async {
await Future.delayed(Duration(seconds: 2));
return 3;
}

Future<String> getStringAfterDelay() async {
await Future.delayed(Duration(seconds: 3));
return 'String data';
}
}

Les données pour la Future seront dans le dataMap lorsque la Future sera exécutée. Chaque Future sera individuellement définie comme busy en utilisant la clé pour la Future transmise. Avec ces fonctionnalités, vous pourrez afficher un loader séparé pour chaque partie de la UI qui dépend de données d'une Future pendant son exécution. Il existe également une fonction hasError qui indiquera si la Future pour une clé spécifique a levé une erreur.

class MultipleFuturesExampleView extends StatelessWidget {


Widget build(BuildContext context) {
return ViewModelBuilder<MultipleFuturesExampleViewModel>.reactive(
builder: (context, viewModel, child) => Scaffold(
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 50,
height: 50,
alignment: Alignment.center,
color: Colors.yellow,
// Indique busy pour la Future number jusqu'à ce que les données soient récupérées ou que l'appel ait échoué
child: viewModel.fetchingNumber
? CircularProgressIndicator()
: Text(viewModel.fetchedNumber.toString()),
),
SizedBox(
width: 20,
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
color: Colors.red,
// Idique busy pour la Future string jusqu'à ce que les données soient récupérées ou que l'appel ait échoué
child: viewModel.fetchingString
? CircularProgressIndicator()
: Text(viewModel.fetchedString),
),
],
),
),
),
viewModelBuilder: () => MultipleFuturesExampleViewModel());
}
}

MultipleStreamViewModel

De manière similaire au StreamViewModel, nous avons également un MultipleStreamViewModel qui vous permet de fournir plusieurs streams via un lien string -> stream. Chaque valeur de ces stream sera stockée dans les data[key] et il en va de même pour les erreurs. Chaque valeur du stream émise appellera notifyListeners() pour mettre à jour la UI. MultipleStreamViewModel nécessite que la streamsMap soit overridée.

const String _NumbersStreamKey = 'numbers-stream';
const String _StringStreamKey = 'string-stream';

class MultipleStreamsExampleViewModel extends MultipleStreamViewModel {
int numbersStreamDelay = 500;
int stringStreamDelay = 2000;


Map<String, StreamData> get streamsMap => {
_NumbersStreamKey: StreamData<int>(numbersStream(numbersStreamDelay)),
_StringStreamKey: StreamData<String>(stringStream(stringStreamDelay)),
};

Stream<int> numbersStream([int delay = 500]) async* {
var random = Random();
while (true) {
await Future.delayed(Duration(milliseconds: delay));
yield random.nextInt(999);
}
}

Stream<String> stringStream([int delay = 2000]) async* {
var random = Random();
while (true) {
await Future.delayed(Duration(milliseconds: delay));
var randomLength = random.nextInt(50);
var randomString = '';
for (var i = 0; i < randomLength; i++) {
randomString += String.fromCharCode(random.nextInt(50));
}
yield randomString;
}
}
}

Tout comme pour le ViewModel à un seul stream unique. Lorsque votre stream a changé, vous devez appeler notifySourceChanged pour indiquer au ViewModel qu'il doit cesser d'écouter le stream actuel et s'abonner au nouveau. Si vous voulez vérifier si le flux a rencontré une erreur, vous pouvez utiliser la fonction hasError avec la clé du flux, vous pouvez également obtenir l'erreur en utilisant getError avec la clé du flux.

IndexTrackingViewModel

Ce ViewModel fournit la fonctionnalité de base nécessaire pour l'index tracking comme la bottom navbar, le side drawer, etc. Il offre des méthodes et des propriétés pour définir et obtenir l'index actuel ainsi qu'une propriété qui indique si reversed doit être utilisé avec des animations lors des transitions de page. Il peut être utilisé dans une View comme suit.

class HomeView extends StatelessWidget {
const HomeView({Key key}) : super(key: key);


Widget build(BuildContext context) {
return ViewModelBuilder<HomeViewModel>.reactive(
builder: (context, viewModel, child) => Scaffold(
body: getViewForIndex(viewModel.currentIndex),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.grey[800],
currentIndex: viewModel.currentTabIndex,
onTap: viewModel.setIndex,
items: [
BottomNavigationBarItem(
title: Text('Posts'),
icon: Icon(Icons.art_track),
),
BottomNavigationBarItem(
title: Text('Todos'),
icon: Icon(Icons.list),
),
],
),
),
viewModelBuilder: () => HomeViewModel(),
);
}

Widget getViewForIndex(int index) {
switch (index) {
case 0:
return PostsView();
case 1:
return TodosView();
}
}
}

Où le ViewModel est simplement celui-ci.

class HomeViewModel extends IndexTrackingViewModel {}

Une autre fonction qu'il possède est setCurrentWebPageIndex qui définit l'index actuel en utilisant la Route actuelle pour le Web. Cette fonction vous permet d'obtenir l'index à partir de l'URL lors d'un refresh de la page. Elle peut être utilisée comme suit :

class BottomNavExampleViewModel extends IndexTrackingViewModel {
final _routerService = exampleLocator<RouterService>();

BottomNavExampleViewModel() {
setCurrentWebPageIndex(_routerService);
}
}

Nous sommes prêts pour le Web 🚀

Maitrisez Flutter pour le web avec le cours Flutter Web officiel.