Flutter i MVVM wyjaśniony w jednym artykule (plus moja implementacja)
Pokażę Ci jak zaimplementować wzorzec MVVM we Flutterze. Dzięki użyciu biblioteki Provider wykonamy aplikację Flutter MVVM.
Długo się do tego zbierałem, ale stało się! W tym artykule pokażę Ci jak połączyć framework Flutter i MVVM.
Wywodzę się ze świata Xamarina. Moją codziennością była praca z frameworkiem MVVMCross a pewne rozwiązania dostępne w nim niesamowicie mi odpowiadają. Toteż na co dzień siłą rzeczy implementowałem podstawowe funkcjonalności oraz dodawałem wiele od siebie, bazując na swoim doświadczeniu jako Flutter developer na własnych włościach.
Co więcej, zauważyłem, że wiele rzeczy powtarza się przy tworzeniu aplikacji mobilnych i mam tu na myśli rozwiązania architektoniczne, użycie powtarzalnych zależności (niezależnie od rodzaju tworzonej aplikacji) ogólnie rzecz biorąc pewien specyficzny dla mnie sposób pracy z Flutterem. Dlatego to, co Ci przedstawię w tym artykule jest częścią mojego frameworka do tworzenia aplikacji w tej technologii.
O tym, jak połączyłem framework Flutter i MVVM przedstawię Ci, tworząc prostą aplikację składającą się tylko z dwóch ekranów: listy oraz detali wybranej pozycji.
Koncepcja, którą przedstawiam w tym artykule, może zawierać pewne dziury czy edge-case’y. Na potrzeby tworzonych przeze mnie aplikacji daje radę, ale biorę pod uwagę, że może pojawić się przypadek, że coś nie zadziała, jak należy.
Daj, proszę znać, jak coś takiego zauważysz. Chętnie poznam Twój punkt widzenia.
Piona! ✋
Flutter i MVVM. Co składa się na całość rozwiązania?
- Bazowy model widoku.
- Silnie typowany stan modelu widoku, który udostępniany jest widokowi.
- Bazowy
Page
z silnie typowanym modelem widoku. - Silnie typowany parametr inicjujący w
ViewModel
. - Implementacja wzorca MVVM z wykorzystaniem biblioteki provider.
- Komunikacja modelu widoku z widokiem oparta o użycie
ValueNotifier
i obiektu stanu.
Co jest Ci potrzebne?
Rozwiązanie opieram o kilka bibliotek.
- equatable — do łatwiejszego porównywania obiektów,
- provider — biblioteka ułatwiająca zarządzanie stanem.
- get_it – do zarządzania zależnościami,
- copy_with_extension – do łatwiejszego tworzenia nowego obiektu na podstawie innego.
Poniżej znajdziesz listę bibliotek do dodania do pliku pubspec.yaml
.
dependencies:
flutter:
sdk: flutter
equatable: ^2.0.5
provider: ^6.0.5
get_it: 7.2.0
copy_with_extension: ^5.0.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.3.2
copy_with_extension_gen: ^5.0.0
Proponowana struktura projektu Flutter
Na poniższym zrzucie ekranu zobaczysz strukturę projektu, którą wykorzystałem na cele tego artykułu. Jakiś czas temu uznałem, że sprawdza mi się przechowywanie modelu widoku, klasy stanu oraz widoku w jednym folderze, którego nazwa opisuje jeden z ekranów. Oczywiście pojawia się potrzeba przechowywania naszych dedykowanych widżetów. Wtedy umieszczam je w folderze widgets
w konkretnej funkcjonalności.
Bazowy ViewModel
Moje podejście zakłada istnienie dwóch bazowych modeli widoków.
Pierwszy z nich bazowy dla całości rozwiązania. Zawiera on podstawowe, podstawowe funkcjonalności. Definiuje on interfejs, udostępnia logikę przetwarzania stanu oraz enkapsuluje użycie ValueNotifier<T>
. Nie zawiera logiki biznesowej konkretnej aplikacji.
Drugi jest tym specyficznym bazowym modelem widoku dla aplikacji. Rozszerza on ten poprzedni wymienoony. Dzięki temu możemy zawrzeć w nim logikę dedykowaną konkretnej aplikacji tj. użycie zależności czy udostępnienie metod dla konkretnych modeli widoków.
Podsumowując, zadaniem pierwszego jest udostępnienie interfejsu, natomiast drugi jest bazą używaną w konkretnej aplikacji.
Bazowy view model
Poniżej kompletna implementacja bazowego modelu widoku. Głównym jego założeniem jest brak logiki związanej z konkretną aplikacją.
Po kolei.
Pierwszy parametr TInitParameter
odpowiada za typ obiektu, który przekazujemy jako argument wejściowy podczas nawigacji do widoku powiązanego z tym modelem widoku. Oznacza to po prostu jakieś dane, które są Ci potrzebne na starcie np. id detalu lub dane wybrane przez użytkownika na poprzednim widoku.
W ramach dodanego założenia VMParameter
musi być typu StateData
. Oznacza on obiekt, który jest stanem modelu widoku. Konkretna klasa powinna zawierać wszelkie propercje, które są potrzebne do komunikacji pomiędzy widokiem a modelem widoku. Dla przykładu może to być lista obiektów, które wyświetlimy na widoku.
Za chwilę stanie się to jaśniejsze.
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
abstract class PBViewModelBase<TInitParameter, TVMParameter extends StateData>
extends ValueListenable<TVMParameter> {
PBViewModelBase(TVMParameter initial)
: _stateDataNotifier = ValueNotifier(initial) {
stateData = initial;
}
late TVMParameter _stateData;
TVMParameter get stateData => _stateData;
Future<void> initialize(TInitParameter parameter);
@mustCallSuper
void onDispose() {
_stateDataNotifier.dispose();
debugPrint("$runtimeType disposed...");
}
final ValueNotifier<TVMParameter> _stateDataNotifier;
@protected
set stateData(TVMParameter value) {
_stateData = value;
_stateDataNotifier.value = value;
}
@override
TVMParameter get value => _stateDataNotifier.value;
@override
void addListener(VoidCallback listener) {
_stateDataNotifier.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
_stateDataNotifier.removeListener(listener);
}
}
abstract class StateData extends Equatable {
const StateData();
}
class NoParam {}
Zaimplementowana logika pozwala na informowanie widoku o zmianach za każdym razem, gdy stan się zmieni. Co w skrócie oznacza zmianę obiektu stateData
.
Bazowy model widoku dla aplikacji
Jestem skłonny powiedzieć, że jest to rzecz opcjonalna, ale w dłuższej perspektywie pomoże Ci zachować porządek w projekcie. Ten bazowy model widoku korzysta z poprzedniego, ale posiada referencję do NavigationService
, który pozwoli nam na nawigację z wnętrza modelu widoku.
import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';
import 'package:pb_flutter_mvvm_provider/services/navigation_service.dart';
abstract class ViewModelBase<TParameter, TVMParameter extends StateData>
extends PBViewModelBase<TParameter, TVMParameter> {
late TParameter parameter;
final NavigationService navigationService = sl();
ViewModelBase(TVMParameter initial) : super(initial);
}
Poniżej znajduje się naiwna implementacja serwisu do nawigacji. W swoich projektach korzystam z bardziej rozbudowanej logiki, ale na potrzeby przykładu uprościłem ją. Zakładam, że kod nie wymaga dodatkowych wyjaśnień.
class NavigationService {
final GlobalKey<NavigatorState> navigatorKey;
NavigationService(this.navigatorKey);
Future<T> navigateToPage<T>(Widget destination, {Object? argument}) async {
return await navigatorKey.currentState?.push(CupertinoPageRoute(
builder: (context) => destination,
settings: RouteSettings(
name: destination.runtimeType.toString(), arguments: argument)));
}
}
Bazowy widok
Kolejna cegiełka w naszym podejściu Flutter MVVM. Kod bazowego widoku jest lekko bardziej skomplikowany. Moje założenie jest takie, że page
jest zawsze typu StatefulWidget
. Stąd użycie tego typu jako bazy. Podczas nawigacji możemy przekazać odpowiedni obiekt, stąd typ TInitParameter
.
Tworząc nowy widok (page’a), musimy użyć ParamStatefulWidget<>
klasy widgetu oraz PBMVVMPage<>
dla klasy stanu. Deklaracja bazy i ograniczeń jest stosunkowo skomplikowana, ale to pozwoli nam na używanie silnego typowania w konkretnych widokach.
Jeśli dany widok nie wymaga parametru wejściowego, należy użyć obiektu typu NoParam
. Chciałem w ten sposób uniknąć null
-parametrów.
W metodzie initState()
prosimy ViewModelFactory
o nowy model widoku oraz wywołujemy metodę initialize()
wraz z parametrem.
Metoda buildContent()
jest abstrakcyjna i musi być zaimplementowana w konkretnym widoku. Ma za zadanie zwrócić widok. Domyślna metoda odpowiedzialna za zbudowanie drzewa widgetów build()
jest już tutaj zaimplementowana, w taki sposób, aby mieć do odpowiedniego typu providera w drzewie widgetów w konkretnym widoku. Ot zwykłe uproszczenie kodu i zwiększenie jego czytelności. Pozostałe metody służą do zarządzania cyklem życia widgetu page'a
.
import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_factory.dart';
import 'package:provider/provider.dart';
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();
abstract class ParamStatefulWidget<TInitParameter> extends StatefulWidget {
final TInitParameter _param;
const ParamStatefulWidget(this._param, {Key? key}) : super(key: key);
TInitParameter get param => _param;
@override
// ignore: no_logic_in_create_state
State createState() => createMVVMState();
PBMVVMPage createMVVMState();
}
abstract class PBMVVMPage<
T extends ParamStatefulWidget,
VM extends PBViewModelBase<TInitParameter, TVMParameter>,
TInitParameter,
TVMParameter extends StateData> extends State<T> with RouteAware {
late final VM _viewModel;
VM get viewModel => _viewModel;
@override
@mustCallSuper
void initState() {
_viewModel = context.read<ViewModelFactory>().create<VM>();
_viewModel.initialize(widget.param);
super.initState();
}
Widget buildContent(BuildContext context);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ValueListenableProvider<TVMParameter>.value(value: viewModel),
],
builder: (context, _) => buildContent(context),
);
}
@override
@mustCallSuper
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
@mustCallSuper
void dispose() {
routeObserver.unsubscribe(this);
viewModel.onDispose();
super.dispose();
}
@override
@mustCallSuper
void didPush() {}
@override
@mustCallSuper
void didPopNext() {
viewModel.onResume();
}
}
Obecna implementacja bazowego widoku
Mam poczucie, że obecna implementacja jest lekko przekombinowana. Mam tu na myśli ilość ograniczeń w kontekście generyczności.
Chyba da się uprościć ten interfejs.
Klasa pomocnicza to zwracania instancji nowych modeli widoku. Moglibyśmy obyć się bez niej, ale wydaje się mieć sens zamknięcie jej tutaj.
abstract class ViewModelFactory {
T create<T extends PBViewModelBase>();
}
class ViewModelFactoryImpl implements ViewModelFactory {
@override
T create<T extends PBViewModelBase>() => sl.get<T>();
}
I widżet GlobalProvider
, którego zadaniem jest udostępnienie obiektu ViewModelFactory
w drzewie widżetów. Opakujemy nim główny widżet aplikacji. Ale o tym za chwilę.
import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_factory.dart';
import 'package:provider/provider.dart';
class GlobalProvider extends StatelessWidget {
const GlobalProvider({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: sl.get<ViewModelFactory>()),
],
child: child,
);
}
}
Ostatnie kroki
Do zarządzania zależnościami korzystam z biblioteki get_it
. W tym celu stwórz plik di.dart
i umieść w nim następujący kod:
final sl = GetIt.instance;
Future<void> initDependencies() async {
sl.registerLazySingleton<ViewModelFactory>(() => ViewModelFactoryImpl());
sl.registerLazySingleton<NavigationService>(
() => NavigationService(navigatorKey));
// sl.registerLazySingleton<PostsRepository>(() => PostsRepository());
// ViewModels
// sl.registerFactory(() => PostsViewModel(sl()));
// sl.registerFactory(() => PostsDetailsViewModel(sl()));
}
Zakomentowany kod będzie przydatny, kiedy już zaczniemy pracę nad aplikacją demo.
import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/features/posts/posts_page.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/global_provider.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initDependencies();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return GlobalProvider(
child: MaterialApp(
title: 'Flutter Demo',
navigatorKey: navigatorKey,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: PostsPage(NoParam())
),
);
}
}
Truskawka na torcie 🍓, czyli lista postów
Do tej pory pokazałem kod, który jest bazą rozwiązania. Czas na aplikację demo, która wykorzysta stworzone komponenty. Jak wspomniałem wcześniej, zrobimy prostą aplikację z listą postów oraz detalami wybranego. Zobaczysz w praktyce kilka przykładów testowych.
Modele
Zacznijmy od stworzenia modelu postu. Umieść go w folderze models
.
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
class Post extends Equatable {
final int id;
final String title;
final String description;
const Post(this.id, this.title, this.description);
@override
List<Object?> get props => [id, title, description];
}
class Posts extends Equatable {
final List<Post> posts;
const Posts(this.posts);
@override
List<Object?> get props => [posts];
@override
bool operator ==(Object other) =>
identical(this, other) || other is List<Post> && listEquals(posts, other);
}
Kolejnym krokiem jest stworzenie klasy, która będzie reprezentować stan listy. Klasy stanu w moim podejściu korzystają z biblioteki copy_with_extension
. Stąd dodatkowy atrybut.
Użycie tej biblioteki ułatwi nam aktualizację stanu, co oznacza, że wystarczy, że na obiekcie stanu wywołamy metodę copyWith()
, a będziemy w stanie zaktualizować dowolną składową. Wkrótce zobaczysz to w działaniu.
part 'posts_state.g.dart';
@CopyWith()
class PostsState extends StateData {
final Posts posts;
final bool isLoading;
const PostsState(this.posts, this.isLoading);
factory PostsState.initial() => const PostsState(Posts([]), true);
@override
List<Object?> get props => [posts, isLoading];
@override
bool? get stringify => false;
}
Po zapisaniu zmian skorzystaj z komendy, która wygeneruje plik posts_state.g.dart
i umożliwi nam korzystanie z dobrodziejstw copy_with
.
flutter pub run build_runner build
Model widoku
Przyszedł czas na dodanie repozytorium, które wykorzystamy w modelu widoku. Co prawda jego implementacja jest dość naiwna, ale nie potrzebujemy nic znacznie bardziej wyrafinowanego, aby przekazać istotę zagadnienia, więc wystarczy co mamy.
Zwróć uwagę, że mieszam dane przed ich zwróceniem w metodzie getPosts()
. Przyda się nam to później, aby udowodnić, że komunikacja pomiędzy widokiem a jego modelem widoku działa poprawnie. Czyli czy kolejność na liście ulegnie zmianie.
import 'package:pb_flutter_mvvm_provider/domain/posts.dart';
class PostsRepository {
final _posts = [
const Post(1, "Post 1",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
const Post(2, "Post 2",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
const Post(3, "Post 3",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
const Post(4, "Post 4",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
];
Future<Posts> getPosts() {
_posts.shuffle();
return Future.value(Posts(_posts));
}
Future<Post> getPost(int postId) {
return Future.value(_posts.firstWhere((element) => element.id == postId));
}
}
I teraz to na tygryski czekały najbardziej — implementacja modelu widoku na listy. Metoda initialize()
wykorzystuje kilka opóźnień, aby zasymulować zmiany na liście. Zwróć uwagę, że po około 2 sekundach lista powinna się zaktualizować, a ma to związek z logiką, którą dodaliśmy do repozytorium wcześniej.
class PostsViewModel extends ViewModelBase<NoParam, PostsState> {
final PostsRepository _postsRepository;
PostsViewModel(this._postsRepository) : super(PostsState.initial());
@override
Future<void> initialize(NoParam parameter) async {
_showLoading();
await Future.delayed(const Duration(milliseconds: 800));
var posts = await _postsRepository.getPosts();
_hideLoading();
stateData = stateData.copyWith(posts: posts);
await Future.delayed(const Duration(milliseconds: 2000));
var postsUpdated = await _postsRepository.getPosts();
stateData = stateData.copyWith(posts: postsUpdated);
return Future.value();
}
Future<void> selectPost(Post selectedPost) {
return Future.value();
}
void _showLoading() {
stateData = stateData.copyWith(isLoading: true);
}
void _hideLoading() {
stateData = stateData.copyWith(isLoading: false);
}
}
To, co niesamowicie podoba mi się w tym podejściu to sposób aktualizacji obiektu stanu. Wykorzystuję silnie typowany obiekt stateData
i przypisuje mu nową wartość, poprzez użycie metody rozszerzającej copyWith
co oznacza, że edytuje tylko to, co powinno zostać zaktualizowane i voilà la!
Widok
Zasadniczo widok jest prosty. Korzystam z widżetu Consumer
, który jest częścią biblioteki provider
. Dzięki temu nasłuchujemy na wszelkie zmiany w obiekcie PostsState
czyli de facto obiekt stateData
.
class PostsPage extends ParamStatefulWidget<NoParam> {
const PostsPage(super.param, {super.key});
@override
PBMVVMPage<ParamStatefulWidget, PBViewModelBase<dynamic, StateData>, dynamic,
StateData> createMVVMState() {
return _PostsPageState();
}
}
class _PostsPageState
extends PBMVVMPage<PostsPage, PostsViewModel, NoParam, PostsState> {
@override
Widget buildContent(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Posts"),
),
body: Consumer<PostsState>(builder: (_, state, child) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return _buildContent(state.posts.posts);
}));
}
Widget _buildContent(List<Post> posts) {
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) => _buildItem(posts[index]),
);
}
Widget _buildItem(Post post) {
final borderRadius = BorderRadius.circular(8.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Card(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
elevation: 5,
child: InkWell(
borderRadius: borderRadius,
onTap: () => viewModel.selectPost(post),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87),
),
Text(
post.description,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
),
);
}
}
Na koniec odkomentuj kod odpowiedzialny za rejestrację zależności PostsViewModel
oraz PostsRepository
w di.dart
.
Ekran detali postu
Moim zdaniem najciekawszy etap. Tym razem sprawdzisz możliwość przekazania danych do innego modelu widoku. Jak poprzednio musimy wykonać następujące kroki.
- Stworzyć klasę stanu i użyć biblioteki
copy_with_extension
. - Uruchomić generator kodu.
- Stworzyć klasę, która będzie użyta podczas nawigacji.
- Stworzyć model widoku.
- Stworzyć widok.
Kolejne pliki stwórz w folderze post_details
.
part 'posts_details_state.g.dart';
@CopyWith()
class PostsDetailsState extends StateData {
final Post post;
final bool isEditable;
const PostsDetailsState(this.post, this.isEditable);
factory PostsDetailsState.initial() =>
const PostsDetailsState(Post(1, "", ""), false);
@override
List<Object?> get props => [post, isEditable];
}
W folderze navparams
dodaj klasę, którą wykorzystamy do przekazania identyfikatora klikniętego postu.
class PostDetailsNavParam {
final int postId;
PostDetailsNavParam(this.postId);
}
class PostsDetailsViewModel
extends ViewModelBase<PostDetailsNavParam, PostsDetailsState> {
final PostsRepository _postsRepository;
Post? _post;
PostsDetailsViewModel(this._postsRepository)
: super(PostsDetailsState.initial());
@override
Future<void> initialize(PostDetailsNavParam parameter) async {
_post = await _postsRepository.getPost(parameter.postId);
stateData = stateData.copyWith(post: _post);
}
void refreshPost() {
if (_post != null) {
_post = Post(_post!.id, _post!.title,
"${_post!.description} \n\n<Description updated at ${DateTime.now().toIso8601String()}>");
stateData = stateData.copyWith(post: _post);
}
}
}
I ostatnia składowa, widok.
class PostsDetailsPage extends ParamStatefulWidget<PostDetailsNavParam> {
const PostsDetailsPage(super.param, {super.key});
@override
PBMVVMPage<ParamStatefulWidget, PBViewModelBase<dynamic, StateData>, dynamic,
StateData> createMVVMState() => _PostsDetailsPageState();
}
class _PostsDetailsPageState extends PBMVVMPage<PostsDetailsPage,
PostsDetailsViewModel, PostDetailsNavParam, PostsDetailsState> {
@override
Widget buildContent(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Details")),
body: Consumer<PostsDetailsState>(
builder: (context, PostsDetailsState state, _) {
return _buildContent(state);
}));
}
Widget _buildContent(PostsDetailsState state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
child: Text(state.post.title,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.deepOrange))),
Expanded(
child: Text(
state.post.description,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87),
),
),
Center(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: Size(130, 50),
backgroundColor: Colors.green,
textStyle: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold)),
onPressed: () => viewModel.refreshPost(),
child: Text("Refresh")),
)
],
),
);
}
Widget _buildItem(Post post) {
final borderRadius = BorderRadius.circular(8.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Card(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
elevation: 5,
child: InkWell(
borderRadius: borderRadius,
// onTap: () => viewModel.selectPost(post),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87),
),
Text(
post.description,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
),
);
}
}
Nie zapomnij o odkomentowaniu rejestracji ostatniego modelu widoku w pliku di.dart
.
Ostatnim krokiem będzie dodanie nawigacji do nowego ekranu. Zaktualizuj metodę selectPost()
w PostsViewModel
.
Future<void> selectPost(Post selectedPost) async {
await navigationService
.navigateToPage(PostsDetailsPage(PostDetailsNavParam(selectedPost.id)));
}
Na tym koniec
Poniżej wizualne demo naszej pracy. Daj, proszę, znać co myślisz o mojej propozycji implementacji Flutter MVVM. Widzisz jakieś dziury, niedociągnięcia? Czy może jednak wygląda to tak dobrze, że nie bałbyś się użyć tego w aplikacji produkcyjnej?
Dodaj komentarz z opinią albo po prostu odezwij się na kontakt@programistabyc.pl.
Link do repozytorium ze zrealizowanym projektem: