SwiftUI - Handling APIs and MVVM Architecture - Part 2
How to make an API call, fetch JSON and structure your networking layer with MVVM
We are going to fetch JSON data from an API and show it in a list view in SwiftUI. I will discuss how I structure my code base with MVVM (Model - View Model - View). Also, I am including an extra layer for the networking logic because it helps make my code more reusable and write unit tests
You will also learn about error handling with URLSession and how to show error information to the user.
- Introduction
- Project organization with MVVM
- Error handling, custom error type
- Separate networking layer and error handling
- Loading view
- Error view
- Breed list views with AsyncImage
- Searchable view modifier to search by breed name
- Cat breed detail view
- Unit tests, mock service, and dependency injection
- Wrap up
We are going to fetch JSON data from a Cat Demo API and show it in a list view
Let us create a new swift UI project, the name is Cat Project, including test.
view / LoadingView.swift
This view is for the progress loader until fetching the data.
import SwiftUI
struct LoadingView: View {
var body: some View {
VStack(spacing: 20) {
Text("😸")
.font(.system(size: 80))
ProgressView()
Text("Getting the cats ...")
.foregroundColor(.gray)
}
}
}
struct LoadingView_Previews: PreviewProvider {
static var previews: some View {
LoadingView()
}
}
..
view / ErrorView.swift
This view is for the show respective error like internet not connected, server error,.
import SwiftUI
struct ErrorView: View {
@ObservedObject var breedFetcher: BreedFetcher
var body: some View {
VStack {
Text("😿")
.font(.system(size: 80))
Text(breedFetcher.errorMessage ?? "")
Button {
breedFetcher.fetchAllBreeds()
} label: {
Text("Try again")
}
}
}
}
struct ErrorView_Previews: PreviewProvider {
static var previews: some View {
ErrorView(breedFetcher: BreedFetcher())
}
}
..
view / BreedRow.swift
This view is for the card UI view item design with asyn image view, title & description view
import SwiftUI
struct BreedRow: View {
let breed: Breed
let imageSize: CGFloat = 100
var body: some View {
HStack {
if breed.image?.url != nil {
AsyncImage(url: URL(string: breed.image!.url!)) { phase in
if let image = phase.image {
image.resizable()
.scaledToFill()
.frame(width: imageSize, height: imageSize)
.clipped()
} else if phase.error != nil {
Text(phase.error?.localizedDescription ?? "error")
.foregroundColor(Color.pink)
.frame(width: imageSize, height: imageSize)
} else {
ProgressView()
.frame(width: imageSize, height: imageSize)
}
}
}else {
Color.gray.frame(width: imageSize, height: imageSize)
}
VStack(alignment: .leading, spacing: 5) {
Text(breed.name)
.font(.headline)
Text(breed.temperament)
}
}
}
}
struct BreedRow_Previews: PreviewProvider {
static var previews: some View {
BreedRow(breed: Breed.example1())
.previewLayout(.fixed(width: 400, height: 200))
}
}
..
view / BreedListView.swift
This view is for the show list of items in the list view/table view including navigation, and search view.
import SwiftUI
struct BreedListView: View {
let breeds: [Breed]
@State private var searchText: String = ""
var filteredBreeds: [Breed] {
if searchText.count == 0 {
return breeds
} else {
return breeds.filter { $0.name.lowercased().contains(searchText.lowercased())
}
}
}
var body: some View {
NavigationView {
List {
ForEach(filteredBreeds) { breed in
NavigationLink {
BreedDetailView(breed: breed)
} label: {
BreedRow(breed: breed)
}
}
}
.listStyle(PlainListStyle())
.navigationTitle("Find Your Perfect Cat")
.searchable(text: $searchText)
}
}
}
struct BreedListView_Previews: PreviewProvider {
static var previews: some View {
BreedListView(breeds: BreedFetcher.successState().breeds)
}
}
..
view / BreedDetailView.swift
This view is for details information in the scroll view
import SwiftUI
struct BreedDetailView: View {
let breed: Breed
let imageSize: CGFloat = 300
var body: some View {
ScrollView {
VStack {
if breed.image?.url != nil {
AsyncImage(url: URL(string: breed.image!.url!)) { phase in
if let image = phase.image {
image.resizable()
.scaledToFit()
.frame( height: imageSize)
.clipped()
} else if phase.error != nil {
Text(phase.error?.localizedDescription ?? "error")
.foregroundColor(Color.pink)
.frame(width: imageSize, height: imageSize)
} else {
ProgressView()
.frame(width: imageSize, height: imageSize)
}
}
}else {
Color.gray.frame(height: imageSize)
}
VStack(alignment: .leading, spacing: 15) {
Text(breed.name)
.font(.headline)
Text(breed.temperament)
.font(.footnote)
Text(breed.breedExplaination)
if breed.isHairless {
Text("hairless")
}
HStack {
Text("Energy level")
Spacer()
ForEach(1..<6) { id in
Image(systemName: "star.fill")
.foregroundColor(breed.energyLevel > id ? Color.accentColor : Color.gray )
}
}
Spacer()
}.padding()
.navigationBarTitleDisplayMode(.inline)
}
}
}
}
struct BreedDetailView_Previews: PreviewProvider {
static var previews: some View {
BreedDetailView(breed: Breed.example1())
}
}
..
networking / APIError.swift
We need to create APIError.swift page in the networking folder, It will handle bad URLs, bad responses, parsing errors, and unknown issues also we are using 'localized description string' for user info messages and 'description string' for debugging information
import Foundation
enum APIError: Error, CustomStringConvertible {
case badURL
case badResponse(statusCode: Int)
case url(URLError?)
case parsing(DecodingError?)
case unknown
var localizedDescription: String {
// user feedback
switch self {
case .badURL, .parsing, .unknown:
return "Sorry, something went wrong."
case .badResponse(_):
return "Sorry, the connection to our server failed."
case .url(let error):
return error?.localizedDescription ?? "Something went wrong."
}
}
var description: String {
//info for debugging
switch self {
case .unknown: return "unknown error"
case .badURL: return "invalid URL"
case .url(let error):
return error?.localizedDescription ?? "url session error"
case .parsing(let error):
return "parsing error \(error?.localizedDescription ?? "")"
case .badResponse(statusCode: let statusCode):
return "bad response with status code \(statusCode)"
}
}
}
..
networking / APIMockService.swift
To fetch data with respective data class format
import Foundation
struct APIMockService: APIServiceProtocol {
var result: Result<[Breed], APIError>
func fetchBreeds(url: URL?, completion: @escaping (Result<[Breed], APIError>) -> Void) {
completion(result)
}
}
..
networking / APIService.swift
import Foundation
import SwiftUI
struct APIService: APIServiceProtocol {
func fetch<T: Decodable>(_ type: T.Type, url: URL?, completion: @escaping(Result<T,APIError>) -> Void) {
guard let url = url else {
let error = APIError.badURL
completion(Result.failure(error))
return
}
let task = URLSession.shared.dataTask(with: url) {(data , response, error) in
if let error = error as? URLError {
completion(Result.failure(APIError.url(error)))
}else if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) {
completion(Result.failure(APIError.badResponse(statusCode: response.statusCode)))
}else if let data = data {
let decoder = JSONDecoder()
do {
let result = try decoder.decode(type, from: data)
completion(Result.success(result))
}catch {
completion(Result.failure(APIError.parsing(error as? DecodingError)))
}
}
}
task.resume()
}
func fetchBreeds(url: URL?, completion: @escaping(Result<[Breed], APIError>) -> Void) {
guard let url = url else {
let error = APIError.badURL
completion(Result.failure(error))
return
}
let task = URLSession.shared.dataTask(with: url) {(data , response, error) in
if let error = error as? URLError {
completion(Result.failure(APIError.url(error)))
}else if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) {
completion(Result.failure(APIError.badResponse(statusCode: response.statusCode)))
}else if let data = data {
let decoder = JSONDecoder()
do {
let breeds = try decoder.decode([Breed].self, from: data)
completion(Result.success(breeds))
}catch {
completion(Result.failure(APIError.parsing(error as? DecodingError)))
}
}
}
task.resume()
}
}
...
networking / APIServiceProtocol.swift
import Foundation
protocol APIServiceProtocol {
func fetchBreeds(url: URL?, completion: @escaping(Result<[Breed], APIError>) -> Void)
}
..
networking / BreedFetcher.swift
import Foundation
class BreedFetcher: ObservableObject {
@Published var breeds = [Breed]()
@Published var isLoading: Bool = false
@Published var errorMessage: String? = nil
let service: APIServiceProtocol
init(service: APIServiceProtocol = APIService()) {
self.service = service
fetchAllBreeds()
}
func fetchAllBreeds() {
isLoading = true
errorMessage = nil
let url = URL(string: "https://api.thecatapi.com/v1/breeds")
service.fetchBreeds(url: url) { [unowned self] result in
DispatchQueue.main.async {
self.isLoading = false
switch result {
case .failure(let error):
self.errorMessage = error.localizedDescription
// print(error.description)
print(error)
case .success(let breeds):
print("--- sucess with \(breeds.count)")
self.breeds = breeds
}
}
}
}
//MARK: preview helpers
static func errorState() -> BreedFetcher {
let fetcher = BreedFetcher()
fetcher.errorMessage = APIError.url(URLError.init(.notConnectedToInternet)).localizedDescription
return fetcher
}
static func successState() -> BreedFetcher {
let fetcher = BreedFetcher()
fetcher.breeds = [Breed.example1(), Breed.example2()]
return fetcher
}
}
...
model/ Breed.swift
import Foundation
/*
[{"weight":{"imperial":"7 - 10","metric":"3 - 5"},"id":"abys","name":"Abyssinian","cfa_url":"http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx","vetstreet_url":"http://www.vetstreet.com/cats/abyssinian","vcahospitals_url":"https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian","temperament":"Active, Energetic, Independent, Intelligent, Gentle","origin":"Egypt","country_codes":"EG","country_code":"EG","description":"The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.","life_span":"14 - 15","indoor":0,"lap":1,"alt_names":"","adaptability":5,"affection_level":5,"child_friendly":3,"dog_friendly":4,"energy_level":5,"grooming":1,"health_issues":2,"intelligence":5,"shedding_level":2,"social_needs":5,"stranger_friendly":5,"vocalisation":1,"experimental":0,"hairless":0,"natural":1,"rare":0,"rex":0,"suppressed_tail":0,"short_legs":0,"wikipedia_url":"https://en.wikipedia.org/wiki/Abyssinian_(cat)","hypoallergenic":0,"reference_image_id":"0XYvRd7oD","image":{"id":"0XYvRd7oD","width":1204,"height":1445,"url":"https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg"}}]
*/
struct Breed: Codable, CustomStringConvertible, Identifiable {
let id: String
let name: String
let temperament: String
let breedExplaination: String
let energyLevel: Int
let isHairless: Bool
let image: BreedImage?
var description: String {
return "breed with name: \(name) and id \(id), energy level: \(energyLevel) isHairless: \(isHairless ? "YES" : "NO")"
}
enum CodingKeys: String, CodingKey {
case id
case name
case temperament
case breedExplaination = "description"
case energyLevel = "energy_level"
case isHairless = "hairless"
case image
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
temperament = try values.decode(String.self, forKey: .temperament)
breedExplaination = try values.decode(String.self, forKey: .breedExplaination)
energyLevel = try values.decode(Int.self, forKey: .energyLevel)
let hairless = try values.decode(Int.self, forKey: .isHairless)
isHairless = hairless == 1
image = try values.decodeIfPresent(BreedImage.self, forKey: .image)
}
init(name: String, id: String, explaination: String, temperament: String,
energyLevel: Int, isHairless: Bool, image: BreedImage?){
self.name = name
self.id = id
self.breedExplaination = explaination
self.energyLevel = energyLevel
self.temperament = temperament
self.image = image
self.isHairless = isHairless
}
static func example1() -> Breed {
return Breed(name: "Abyssinian",
id: "abys",
explaination: "The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.",
temperament: "Active, Energetic, Independent, Intelligent, Gentle",
energyLevel: 5,
isHairless: false, image: BreedImage(height: 100, id: "i", url: "https://cdn2.thecatapi.com/images/unX21IBVB.jpg", width: 100))
}
static func example2() -> Breed {
return Breed(name: "Cyprus",
id: "cypr",
explaination: "Loving, loyal, social and inquisitive, the Cyprus cat forms strong ties with their families and love nothing more than to be involved in everything that goes on in their surroundings. They are not overly active by nature which makes them the perfect companion for people who would like to share their homes with a laid-back relaxed feline companion.",
temperament: "Affectionate, Social",
energyLevel: 4,
isHairless: false,
image: BreedImage(height: 100, id: "i", url: "https://cdn2.thecatapi.com/images/unX21IBVB.jpg", width: 100))
}
}
..
model/ BreedImage.swift
import Foundation
/*
"image": {
"height": 1445,
"id": "0XYvRd7oD",
"url": "https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg",
"width": 1204
},
*/
struct BreedImage: Codable {
let height: Int?
let id: String?
let url: String?
let width: Int?
}
..
CatAPIProjectApp.swift
import SwiftUI
@main
struct CatAPIProjectApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
..
ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject var breedFetcher = BreedFetcher()
var body: some View {
if breedFetcher.isLoading {
LoadingView()
}else if breedFetcher.errorMessage != nil {
ErrorView(breedFetcher: breedFetcher)
}else {
BreedListView(breeds: breedFetcher.breeds)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
..
Comments
Post a Comment