Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Flutter

MVVM in Flutter using ScopedModel

5.00/5 (12 votes)
10 Oct 2018CPOL3 min read 33.8K  
Implementing the MVVM pattern in Flutter using ScopedModel

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.

C#
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.

C#
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.

C#
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.

C#
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.

C#
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.

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)