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