Usage of MVVM pattern in iOS development iOS 10.03.2022

MVVM – the Model View ViewModel architecture, is a design approach for application to ensure there is no tight coupling between your UI (V), business logic (VM) and the data layer (M). Any future changes of any of these components should not affect one another.

  • Model represents the data from the datasource, which can be from remote URL or local. A Model should just be kept as simple as to reflect directly what the data structure of the datasource is.
  • ViewModel is the bridge between the View and the Model. A ViewModel is used by View for displaying different View states but it should not be aware what View is using it. A ViewModel should contain some business logic for providing the data from the Model in the forms required by the View; so there is no tight coupling between the View and the Model. A ViewModel should also update a Model when needed.
  • View it’s the UI i.e. made up of a combination of List, ScrollView, Text, Image and many others in SwiftUI. In an MVVM approach, View is dependent on ViewModel to provide it with different View states. And it also informs the ViewModel about the user interactions.

One of the key in MVVM design pattern is the separation of concern. MVVM has the following restrictions

  • ViewModel requests some API Service and API Service will sends a response to ViewModel.
  • ViewModel only talks to Model.
  • Model doesn't talk to anybody.
  • View can't talk to Model directly (only interacts with ViewModel and other views).
  • View only talks to the ViewModel, notifying them of interaction events.
  • ViewModel is owned by the View and the Model is owned by the ViewModel.

Following snippets is practical usage of MVVM approach with network (URLSession, Combine) and database (CoreData) environments.

Example 1. URLSession

In first example we'll fetch a post from jsonplaceholder.typicode.com and show it in UI via URLSession.

View

import SwiftUI

struct ContentView: View {
    @StateObject var vm = PostViewModel()

    var body: some View {
        NavigationView {
            VStack {
                Text(vm.title).font(.system(size: 24))
                Text(vm.body).font(.system(size: 20))
            }
        }.navigationTitle("Post")
    }
}

ViewModel

import UIKit

class PostViewModel: ObservableObject {
    @Published var title: String = "-"
    @Published var body: String = "-"

    init() {
        fetchPost()
    }

    func fetchPost() {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else {
            return
        }
        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            guard let data = data, error == nil else { return }
            do {
                let model = try JSONDecoder().decode(Post.self, from: data)

                DispatchQueue.main.async {
                    self.title = model.title
                    self.body = model.body
                }
            } catch {
                print("Failed")
            }
        }
        task.resume()
    }
}

Model

import Foundation

struct Post: Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

Example 2. Combine (SwiftUI)

In second example we'll fetch posts from jsonplaceholder.typicode.com and show it in UI via Combine.

ContentView

import SwiftUI

struct ContentView: View {        
    var body: some View {
        PostsView()
    }
}

List View

import SwiftUI

struct PostsView: View {
    @ObservedObject var vm = PostViewModel()

    var body: some View {
        NavigationView {
            List(vm.posts, id: \.self) {
                PostView(post: $0)
            }
            .navigationBarTitle("Posts")
            .onAppear {
                self.viewModel.fetchPosts()
            }
        }
    }
}

List Item View

import SwiftUI

struct PostView: View {
    private let post: Post

    init(post: Post) {
        self.post = post
    }

    var body: some View {
        HStack {
            Image(systemName: "crown")
                .resizable()
                .scaledToFit()
                .frame(width: 80, height: 80)
            VStack(alignment: .leading, spacing: 15) {
                Text(post.title)
                    .font(.system(size: 12))
                    .foregroundColor(Color.green)
                Text(post.body)
                    .font(.system(size: 10))
            }
        }
    }
} 

ViewModel

import Foundation
import Combine

class PostViewModel: ObservableObject {
    private let url = "https://jsonplaceholder.typicode.com/posts/"
    private var task: AnyCancellable?

    @Published var posts: [Post] = []

    func fetchPosts() {
        task = URLSession.shared.dataTaskPublisher(for: URL(string: url)!)
            .map { $0.data }
            .decode(type: [Post].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .eraseToAnyPublisher()
            .receive(on: RunLoop.main)
            .assign(to: \PostViewModel.posts, on: self)
    }
}

Model

struct Post: Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

Example 3. Combine (UIKit)

To easy AutoLayout I'm going to use SnapKit and add all the UI elements into the view controller programmatically.

ViewModel

import Foundation
import Combine

enum ViewModelState {
    case loading
    case finishedLoading
    case error
}

class PostViewModel: ObservableObject {
    @Published private(set) var posts: [Post] = []
    @Published private(set) var state: ViewModelState = .loading

    private var subsciptions = Set<AnyCancellable>()
    private let url = "https://jsonplaceholder.typicode.com/posts/"

    init() {
        loadPosts()
    }

    func loadPosts() {
        state = .loading

        let valueHandler: ([Post]) -> Void = { [weak self] items in
            self?.posts = items
        }

        let completionHandler: (Subscribers.Completion<Error>) -> Void = { [weak self] completion in
            switch completion {
            case .failure:
                self?.state = .error
            case .finished:
                self?.state = .finishedLoading
            }
        }

        URLSession.shared.dataTaskPublisher(for: URL(string: url)!)
                    .map { $0.data }
                    .decode(type: [Post].self, decoder: JSONDecoder())
                    .sink(receiveCompletion: completionHandler, receiveValue: valueHandler)
                    .store(in: &subsciptions)
    }
}

Model

struct Post: Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

ViewController

import UIKit
import Combine

class PostsController: UIViewController {
    private let tbl: UITableView = {
        let tv = UITableView()
        tv.backgroundColor = UIColor.white
        tv.translatesAutoresizingMaskIntoConstraints = false
        return tv
    }()

    private let vm = PostViewModel()

    private var subsciptions = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupViewModel()
    }

    private func setupTableView() {
        tbl.delegate = self
        tbl.dataSource = self
        tbl.register(UITableViewCell.self, forCellReuseIdentifier: "cellId")

        view.addSubview(tbl)
        tbl.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }

    private func setupViewModel() {
        vm.$posts
            .receive(on: RunLoop.main)
            .sink(receiveValue: { [weak self] _ in
                self?.tbl.reloadData()
            })
            .store(in: &subsciptions)

        let stateHandler: (ViewModelState) -> Void = { [weak self] state in
            switch state {
            case .loading:
                print("loading")
            case .finishedLoading:
                print("finished")
            case .error:
                print("error")
            }
        }

        vm.$state
            .receive(on: RunLoop.main)
            .sink(receiveValue: stateHandler)
            .store(in: &subsciptions)
    }
}

extension PostsController: UITableViewDataSource, UITableViewDelegate  {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return vm.posts.count;
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cellId", for: indexPath)
        cell.textLabel?.text = vm.posts[indexPath.row].title
        return cell
    }
}

Example 4. CoreData

View

import SwiftUI

struct ContentView: View {    
    @StateObject private var vm = MoviesViewModel()

    var body: some View {
        VStack {
            HStack {
                TextField("Enter name", text: $vm.title)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                Button("Save") {
                    vm.save()
                    vm.title = ""
                    vm.getAllItems()
                }
            }

            List {
                ForEach(vm.movies, id: \.id) { movie in
                    Text(movie.title)
                }
                .onDelete(perform: deleteMovie)
            }
            Spacer()
        }
        .padding()
        .onAppear(perform: {
            vm.getAllItems()
        })
    }

    func deleteMovie(at offsets: IndexSet) {
        offsets.forEach { index in
            let movie = vm.movies[index]
            vm.delete(movie)
            vm.getAllItems()
        }
    }
}

ViewModel

import Foundation
import CoreData

class MoviesViewModel: ObservableObject {
    var title: String = ""
    @Published var movies: [Movie] = []

    func getAllItems() {
        movies = CoreDataManager.shared.getAllMovies()
    }

    func save() {
        let movie = Movie(context: CoreDataManager.shared.viewContext)
        movie.title = title

        CoreDataManager.shared.save()
    }

    func delete(_ movie: Movie) {
        let movieDb = CoreDataManager.shared.getMovieById(movie.id)
        if let movieDb = movieDb {
            CoreDataManager.shared.delete(movie: movieDb)
        }
    }
}

Model

struct Movie {
    let movie: MovieData

    var id: NSManagedObjectID {
        return movie.objectID
    }

    var title: String {
        return movie.title ?? ""
    }
}

Manager

import Foundation
import CoreData

class CoreDataManager {
    let persistentContainer: NSPersistentContainer
    static let shared = CoreDataManager()
    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

    private init() {
        persistentContainer = NSPersistentContainer(name: "Movies")
        persistentContainer.loadPersistentStores { (description, error) in
            if let error = error {
                fatalError("Unresolved error \(error)")
            }
        }
    }

    func save() {
        do {
            try viewContext.save()
        } catch {
            viewContext.rollback()
            print(error.localizedDescription)
        }
    }

    func getAllMovies() -> [Movie] {
        let request: NSFetchRequest<Movie> = Movie.fetchRequest()
        do {
            return try viewContext.fetch(request)
        } catch {
            print(error.localizedDescription)
            return []
        }
    }

    func getMovieById(_ id: NSManagedObjectID) -> Movie? {
        do {
            return try viewContext.existingObject(with: id) as? Movie
        } catch {
            return nil
        }
    }

    func delete(movie: Movie) {
        viewContext.delete(movie)
        save()
    }
}