Introduction
In this article, I will demonstrate how to go about implementing the MVVM pattern in Flutter using scoped_model. Scoped model is a utility that enables a reactive model to be passed to a child of a ScopedModel
widget and its descendants. By simply calling the utility's notifyListeners()
method, in the model, you initiate the equivalent of calling setState()
and cause ScopedModelDescendant
widget[s] to be rebuilt – If you are lost on this explanation, don't worry, you shall understand this better as I go through the code from the article's project.
As you can see from the screenshots above, the sample application displays lists of Star Wars related data: specifically films, characters, and planets. The app gets this data from Swapi, the Star Wars API. Since Swapi is an open API, authentication is not required when making HTTP requests so you don't have to worry about getting an API key.
Prerequisites
To follow along with this article, you should be familiar with Flutter and the MVVM pattern. You should also clone or download the project from GitHub.
Dependencies
As I mentioned in the introduction section, the project makes use of ScopedModel
and so a dependency on the scoped_model
package is added to the pubsec.yaml file. I'm also making use of the font_awesome_flutter package and the Distant Galaxy font: The latter is for the title text in the app bar while the former is for displaying most of the icons.
name: flutter_mvvm_example
description: Flutter MVVM example project.
dependencies:
flutter:
sdk: flutter
...
scoped_model: ^0.3.0
font_awesome_flutter: ^8.0.1
...
flutter:
...
fonts:
- family: Distant Galaxy
fonts:
- asset: fonts/DISTGRG_.ttf
Models
There are three model classes in the project that represent the data that is fetched from the Star Wars API. You will find them in the lib > models folder.
class Film {
String title, openingCrawl, director, producer;
DateTime releaseDate;
Film({
this.title,
this.openingCrawl,
this.director,
this.producer,
this.releaseDate,
});
Film.fromMap(Map<String, dynamic> map) {
title = map['title'];
openingCrawl = map['opening_crawl'];
director = map['director'];
producer = map['producer'];
releaseDate = DateTime.parse(map['release_date']);
}
}
class Character {
String name, birthYear, gender, eyeColor;
int height;
Character({
this.name,
this.birthYear,
this.gender,
this.height,
this.eyeColor,
});
Character.fromMap(Map<String, dynamic> map) {
name = map['name'];
birthYear = map['birth_year'];
gender = map['gender'];
height = int.parse(map['height']);
eyeColor = map['eye_color'];
}
}
class Planet {
String name, climate, terrain, gravity, population;
int diameter;
Planet({
this.name,
this.climate,
this.terrain,
this.diameter,
this.gravity,
this.population,
});
Planet.fromMap(Map<String, dynamic> map) {
name = map['name'];
climate = map['climate'];
terrain = map['terrain'];
diameter = int.parse(map['diameter']);
gravity = map['gravity'];
population = map['population'];
}
}
View Models
There's only one view model in the project, MainPageViewModel
. This class extends scoped_model
's Model
class and is where the notifyListeners()
method is called.
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:flutter_mvvm_example/interfaces/i_star_wars_api.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/models/character.dart';
import 'package:flutter_mvvm_example/models/planet.dart';
class MainPageViewModel extends Model {
Future<List<Film>> _films;
Future<List<Film>> get films => _films;
set films(Future<List<Film>> value) {
_films = value;
notifyListeners();
}
Future<List<Character>> _characters;
Future<List<Character>> get characters => _characters;
set characters(Future<List<Character>> value) {
_characters = value;
notifyListeners();
}
Future<List<Planet>> _planets;
Future<List<Planet>> get planets => _planets;
set planets(Future<List<Planet>> value) {
_planets = value;
notifyListeners();
}
final IStarWarsApi api;
MainPageViewModel({@required this.api});
Future<bool> setFilms() async {
films = api?.getFilms();
return films != null;
}
Future<bool> setCharacters() async {
characters = api?.getCharacters();
return characters != null;
}
Future<bool> setPlanets() async {
planets = api?.getPlanets();
return planets != null;
}
}
The notifyListeners()
method is called in the setters – If you're familiar with WPF, and especially MVVM in WPF, what I'm doing is similar to calling OnPropertyChanged()
in the setters of properties in the view models.
The asynchronous functions in MainPageViewModel
can be called to fetch the required data and set the necessary properties which will trigger the rebuilding of any ScopedModelDescendant<MainPageViewModel>
widgets.
Fetching Data
I've written a service for fetching the required data from the Star Wars API which you can find in the lib > services folder. The service implements IStarWarsApi
, which defines functions for getting collections of certain types of data.
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_mvvm_example/interfaces/i_star_wars_api.dart';
import 'package:flutter_mvvm_example/models/character.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/models/planet.dart';
class SwapiService implements IStarWarsApi {
final _baseUrl = 'https://swapi.co/api';
static final SwapiService _internal = SwapiService.internal();
factory SwapiService () => _internal;
SwapiService.internal();
Future<dynamic> _getData(String url) async {
var response = await http.get(url);
var data = json.decode(response.body);
return data;
}
Future<List<Film>> getFilms() async {
var data = await _getData('$_baseUrl/films');
List<dynamic> filmsData = data['results'];
List<Film> films = filmsData.map((f) => Film.fromMap(f)).toList();
return films;
}
Future<List<Character>> getCharacters() async {
var data = await _getData('$_baseUrl/people');
List<dynamic> charactersData = data['results'];
List<Character> characters =
charactersData.map((c) => Character.fromMap(c)).toList();
return characters;
}
Future<List<Planet>> getPlanets() async {
var data = await _getData('$_baseUrl/planets');
List<dynamic> planetsData = data['results'];
List<Planet> planets = planetsData.map((p) => Planet.fromMap(p)).toList();
return planets;
}
}
Views
As you've seen from the screenshots at the beginning of the article, the app has a scaffold with an app bar containing the "Star Wars" title text and a tab bar with three tabs. This scaffold is defined in a widget named MainPage
.
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
MainPageViewModel viewModel;
TabController tabController;
@override
void initState() {
super.initState();
viewModel = MainPageViewModel(api: SwapiService());
tabController = TabController(vsync: this, length: 3);
loadData();
}
Future loadData() async {
await model.fetchFilms();
await model.fetchCharacters();
await model.fetchPlanets();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(
'Star Wars',
style: TextStyle(
fontFamily: 'Distant Galaxy',
),
),
bottom: TabBar(
controller: tabController,
indicatorColor: Colors.white,
indicatorWeight: 3.0,
tabs: <Widget>[
Tab(icon: Icon(FontAwesomeIcons.film)),
Tab(icon: Icon(FontAwesomeIcons.users)),
Tab(icon: Icon(FontAwesomeIcons.globeAmericas))
],
),
),
body: ScopedModel<MainPageViewModel>(
model: viewModel,
child: TabBarView(
controller: tabController,
children: <Widget>[
FilmsPanel(),
CharactersPanel(),
PlanetsPanel(),
],
),
),
);
}
@override
void dispose() {
tabController?.dispose();
super.dispose();
}
}
The TabView
is wrapped in a ScopedModel
widget so its descendants, which are wrapped in ScopeModelDescendant
widgets, will have access to the data model of type MainPageViewModel
and will be rebuilt whenever notifyListeners()
is called.
The FilmsPanel
widget contains the ListView
that displays the list of Star Wars films.
import 'package:flutter/material.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/view_models/main_page_view_model.dart';
import 'package:flutter_mvvm_example/views/widgets/films_list_item.dart';
import 'package:flutter_mvvm_example/views/widgets/no_internet_connection.dart';
import 'package:scoped_model/scoped_model.dart';
class FilmsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ScopedModelDescendant<MainPageViewModel>(
builder: (context, child, model) {
return FutureBuilder<List<Film>>(
future: model.films,
builder: (_, AsyncSnapshot<List<Film>> snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.active:
case ConnectionState.waiting:
return Center(child: const CircularProgressIndicator());
case ConnectionState.done:
if (snapshot.hasData) {
var films = snapshot.data;
return ListView.builder(
itemCount: films == null ? 0 : films.length,
itemBuilder: (_, int index) {
var film = films[index];
return FilmsListItem(film: film);
},
);
} else if (snapshot.hasError) {
return NoInternetConnection(
action: () async {
await model.setFilms();
await model.setCharacters();
await model.setPlanets();
},
);
}
}
},
);
},
);
}
}
FilmsListItem
is a widget that displays an item in the ListView
.
import 'package:flutter/material.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/utils/star_wars_styles.dart';
class FilmsListItem extends StatelessWidget {
final Film film;
FilmsListItem({@required this.film});
@override
Widget build(BuildContext context) {
var title = Text(
film?.title,
style: TextStyle(
color: StarWarsStyles.titleColor,
fontWeight: FontWeight.bold,
fontSize: StarWarsStyles.titleFontSize,
),
);
var subTitle = Row(
children: <Widget>[
Icon(
Icons.movie,
color: StarWarsStyles.subTitleColor,
size: StarWarsStyles.subTitleFontSize,
),
Container(
margin: const EdgeInsets.only(left: 4.0),
child: Text(
film?.director,
style: TextStyle(
color: StarWarsStyles.subTitleColor,
),
),
),
],
);
return Column(
children: <Widget>[
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20.0),
title: title,
subtitle: subTitle,
),
Divider(),
],
);
}
}
You can check out the rest of the widgets that are used to display the characters and planets in the lib > views > widgets folder.
Conclusion
That's it! I hope you have learned something useful from this article. If you haven't yet downloaded the article project, make sure you do so and go through the code.
History
- 20th August, 2018: Initial post
- 10th October, 2018: Updated code