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.
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
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() } }