Updated 11 December 2023
In this blog, we will create a project with SwiftUI and MVVM design. If you want to know about the basic part of SwiftUI you can refer here.
It is a declarative framework for building applications for iOS devices. This means that instead of using Storyboards or programmatically generating your interface, we can use the simplicity of the SwiftUI framework.
It is a cross-platform that works across iOS, macOS, tvOS, and watchOS and lets you replace Interface Builder (IB) and storyboards.
IB and Xcode were separate apps before Xcode 4, and the seams still show every time you edit the name of an IBAction
or IBOutlet
or delete it from your code, and your app crashes because IB doesn’t see code changes. SwiftUI also works with UIKit — like Swift and Objective-C.
It does not follow the MVC pattern means when you create a new application in Xcode, it does not create any controllers.
But this does not mean that you can’t use the MVC with it, but MVVM suits more with it.
Let’s explore the MVVM with iOS app architecture by building a simple Country Detail App.
Create the network manager which will help to retrieve the response from the API. The Network Manager is implemented as given below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
class NetworkManager: NSObject { var languageBundle: Bundle! var fileURL: URL! class var sharedInstance: NetworkManager { struct Singleton { static let instance = NetworkManager() } return Singleton.instance } func callingHttpRequest(completion: @escaping ([CountryData]?) -> ()){ let urlString: String = "http://countryapi.gear.host/v1/Country/getCountries" URLSession.shared.dataTask(with: URL(string: urlString)!) { data, response, error in guard let data = data, error == nil else { completion(nil) return } let response = try? JSONDecoder().decode(CountryModel.self, from: data) if let response = response{ DispatchQueue.main.async { completion(response.response) } } }.resume() } } struct TemporaryImageCache: ImageCache { private let cache = NSCache<NSURL, UIImage>() subscript(_ key: URL) -> UIImage? { get { cache.object(forKey: key as NSURL) } set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) } } } struct ImageCacheKey: EnvironmentKey { static let defaultValue: ImageCache = TemporaryImageCache() } extension EnvironmentValues { var imageCache: ImageCache { get { self[ImageCacheKey.self] } set { self[ImageCacheKey.self] = newValue } } } protocol ImageCache { subscript(_ url: URL) -> UIImage? { get set } } |
The callingHttpRequest function call all response from API. We have also used NSCache to store the image in the cache.
Now we will work with the model and let’s create the CountryData and CountryModel as:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
struct CountryModel: Codable { let response: [CountryData]? init(response: [CountryData]?) { self.response = response } init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) response = try values.decode([CountryData].self, forKey: .response) } enum CodingKeys: String, CodingKey { case response = "Response" } } struct CountryData: Codable { let flagPng: String? let name: String? let currencyCode: String? var flagImage:UIImage = UIImage() init(flagPng: String?, name: String, currencyCode: String) { self.flagPng = flagPng self.name = name self.currencyCode = currencyCode } init(from decoder:Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) flagPng = try values.decode(String.self, forKey: .flagPng) name = try values.decode(String.self, forKey: .name) currencyCode = try values.decode(String.self, forKey: .currencyCode) } enum CodingKeys: String, CodingKey { case flagPng = "FlagPng" case name = "Name" case currencyCode = "CurrencyCode" } } |
The CountryModels will get the data from the Network Manager. Then hand over it to the View Models which is responsible for creating the logic and tricks.
It is responsible to create the transformation of data from the model to view and vice versa and reflecting the contents of the entire screen.
Open ViewModelData.swift and add:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class ViewModelData: ObservableObject { @Published var countryData = [ViewModelListData]() init() { makeRequest() } private func makeRequest() { NetworkManager.sharedInstance.callingHttpRequest{ countryData in if let countryData = countryData { self.countryData = countryData.map(ViewModelListData.init) } } } } class ViewModelListData: Identifiable { let id = UUID() var countryData: CountryData init(countryData: CountryData) { self.countryData = countryData } } |
The ViewModelListData conforms to the Identifiable protocol to supply data to the List. The List uses the id property to make sure that the contents of the list are unique.
We have conforms to the ObservableObject protocol. This means that it can publish events to the subscribers and can be bound to views on the screen.
The Combine’s way of making a model observable is by conforming to the ObservableObject
protocol. To bind image updates to a view, we add the @Published
property wrapper as:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
class ImageViewer: ObservableObject { @Published var image: UIImage? private let url: URL private var cancellable: AnyCancellable? private var cache: ImageCache? init(url: URL) { self.url = url } deinit { cancel() } init(url: URL, cache: ImageCache? = nil) { self.url = url self.cache = cache } func load() { if let image = cache?[url] { self.image = image return } cancellable = URLSession.shared.dataTaskPublisher(for: url) .map { UIImage(data: $0.data) } .replaceError(with: nil) .handleEvents(receiveOutput: { [weak self] in self?.cache($0) }) .receive(on: DispatchQueue.main) .sink { [weak self] in self?.image = $0 } } private func cache(_ image: UIImage?) { image.map { cache?[url] = $0 } } func cancel() { cancellable?.cancel() } } |
The purpose of the view is to display an image provided with its URL. It depends on ImageViewer which fetches an image from the network and emits image updates via a Combine publisher.
We bind AsyncNewImage
to image updates by the @StateObject
property wrapper. It will automatically rebuild the view every time the image is changed or updated as:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
struct AsyncNewImage<Placeholder: View>: View { @StateObject private var loader: ImageViewer private let placeholder: Placeholder init(url: URL, @ViewBuilder placeholder: () -> Placeholder) { self.placeholder = placeholder() _loader = StateObject(wrappedValue: ImageViewer(url: url, cache: Environment(\.imageCache).wrappedValue)) } var body: some View { content .onAppear(perform: loader.load) } private var content: some View { Group { if loader.image != nil { Image(uiImage: loader.image!) .resizable() .aspectRatio(contentMode: .fit) .frame(width:100, height:100) } else { placeholder } } } } |
Lastly, to test our component, add the following code to ContentView
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct ContentView: View { @ObservedObject var model = ViewModelData() var body: some View { List(model.countryData) { country in HStack() { AsyncNewImage(url: URL(string: country.countryData.flagPng ?? "")!, placeholder: { Text("Loading ...") }) .frame(minHeight: 100, maxHeight: 100) .aspectRatio(2 / 3, contentMode: .fit) VStack(alignment: .leading) { Text (country.countryData.name ?? "") .bold() .lineLimit(1) .fixedSize() Text(country.countryData.currencyCode ?? "") } } } } } |
The model is marked with @ObservedObject which means when the model is eventually set, after the asynchronous call, it will render the screen again by executing the body property.
Build and run the project. Everything should work as expected! Congratulations on creating your SwiftUI app with MVVM! :]
. . . . . . . . . . . . . . . .
Thanks for reading, I hope this blog will help you.
If you have any queries please ask me on the comment.
If you have more details or questions, you can reply to the received confirmation email.
Back to Home
Be the first to comment.