SwiftUI - API Load Placeholder

When loading data from an api it is best practice to show a mock view of the content that is being fetched. Here is how to do that

Sun, July 24 2022

jake hockey

Jake Landers

Developer and Creator

hey

The SwiftUI build in ProgressView is great and all, but sometimes you need something a little more immsersive. Thats where having a preview of what is going to be loaded comes in to play! This is a simple demonstration of fetching data from json, displaying a preview while it loads, and then showing the actual content when ready.

1.png

Client

First, we need a way to fetch the data and store it in objects for use in swiftUI code.

1class Client: ObservableObject { 2 @Published var loadingStatus: LoadingStatus = LoadingStatus.initial 3 4 @Published var account: Account? 5 private var accountResponse: AccountResponse? { 6 didSet { 7 if accountResponse != nil { 8 if accountResponse!.status == 200 { 9 print("successfully fetched account data!") 10 account = accountResponse!.body 11 loadingStatus = .success 12 } else { 13 print(accountResponse!.message) 14 loadingStatus = .failure 15 } 16 } else { 17 print("There was a fatal error fetching account data.") 18 loadingStatus = .failure 19 } 20 } 21 } 22 23 init() { 24 fetchWithDelay() 25 } 26 27 func fetchWithDelay() { 28 loadingStatus = .loading 29 // add artificial network lag 30 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 31 async { 32 await self.fetchData() 33 } 34 } 35 } 36 37 func fetchData() async { 38 DispatchQueue.main.async { 39 self.loadingStatus = .loading 40 } 41 42 // get the data from the url 43 guard let url = Bundle.main.url(forResource: "data.json", withExtension: nil) else { 44 DispatchQueue.main.async { 45 self.loadingStatus = .failure 46 } 47 return 48 } 49 do { 50 let data = try Data(contentsOf: url) 51 let decoder = JSONDecoder() 52 let response = try decoder.decode(AccountResponse.self, from: data) 53 DispatchQueue.main.async { 54 self.accountResponse = response 55 } 56 } catch { 57 print("There was an error fetching or decoding the data") 58 DispatchQueue.main.async { 59 self.loadingStatus = .failure 60 } 61 return 62 } 63 64 // if fetching from data base use this: 65// guard let url = URL(string: "") else { 66// return 67// } 68// do { 69// let (data, _) = try await URLSession.shared.data(from: url) 70// let decoder = JSONDecoder() 71// let response = try decoder.decode(AccountResponse.self, from: data) 72// accountResponse = response 73// } catch { 74// print("There was an error fetching or decoding the data") 75// loadingStatus = .failure 76// return 77// } 78 } 79 80 // for generating a color based on the vendor 81 func randomColor(seed: String) -> Color { 82 83 var total: Int = 0 84 for u in seed.unicodeScalars { 85 total += Int(UInt32(u)) 86 } 87 88 srand48(total * 200) 89 let r = CGFloat(drand48()) 90 91 srand48(total) 92 let g = CGFloat(drand48()) 93 94 srand48(total / 200) 95 let b = CGFloat(drand48()) 96 97 return Color(red: r, green: g, blue: b) 98 } 99} 100 101class AccountResponse: Codable { 102 let status: Int 103 let message: String 104 let body: Account? 105} 106 107class Account: Codable { 108 let name: String 109 let currentBalance: Double 110 let transactions: [Transaction] 111} 112 113class Transaction: Codable { 114 let type: Int 115 let vendor: String 116 let amount: Double 117 let date: String 118} 119 120enum LoadingStatus { 121 case initial 122 case loading 123 case success 124 case failure 125} 126

Loading Cell

Now, we need to create what our loading cell will look like. For this example, I am going to have the opacity oscillate between 0.5 and 1 to give user feedback that something is actually happening.

1struct LoadingCell: View { 2 @Environment(\.colorScheme) var colorScheme 3 4 @State private var isAnimating = false 5 6 var body: some View { 7 HStack(spacing: 10) { 8 Circle() 9 .frame(width: 60, height: 60) 10 VStack(alignment: .leading) { 11 Rectangle() 12 .frame(height: 10) 13 Rectangle() 14 .frame(width: UIScreen.main.bounds.width / 2, height: 10) 15 } 16 } 17 .opacity(isAnimating ? 0.5 : 1) 18 .foregroundColor(colorScheme == .light ? Color.black.opacity(0.3) : Color.white.opacity(0.3)) 19 .onAppear { 20 withAnimation(Animation.easeInOut(duration: 0.8).repeatForever()) { 21 isAnimating = true 22 } 23 } 24 .onDisappear { 25 isAnimating = false 26 } 27 } 28} 29

View

Finally, lets put it all together.

1struct ContentView: View { 2 @Environment(\.colorScheme) var colorScheme 3 4 @StateObject var client = Client() 5 var body: some View { 6 NavigationView { 7 Group { 8 switch client.loadingStatus { 9 case .loading: 10 loading 11 case .success: 12 success 13 case .initial: 14 loading 15 case .failure: 16 Text("Failure") 17 } 18 } 19 .navigationTitle("Transactions") 20 } 21 } 22 23 private var success: some View { 24 Group { 25 if client.account != nil { 26 List { 27 HStack { 28 VStack(alignment: .leading) { 29 Text(client.account!.name) 30 .fontWeight(.bold) 31 .font(.system(.title2)) 32 HStack { 33 Text("Account Balance:") 34 Spacer(minLength: 0) 35 Text("\(client.account!.currentBalance, specifier: "%.2f")") 36 } 37 .foregroundColor(colorScheme == .light ? Color.black.opacity(0.7) : Color.white.opacity(0.7)) 38 } 39 } 40 Section { 41 ForEach(client.account!.transactions, id:\.date) { transaction in 42 VStack { 43 transactionCell(transaction: transaction) 44 } 45 } 46 } 47 } 48 .refreshable { 49 // if fetching data from the internet, use 50// await client.fetchData() 51 } 52 } else { 53 Text("There was an unknwon issue") 54 } 55 } 56 } 57 58 private func transactionCell(transaction: Transaction) -> some View { 59 return HStack(spacing: 10) { 60 vendorIcon(name: transaction.vendor) 61 VStack(alignment: .leading) { 62 Text(transaction.vendor) 63 .font(.system(size: 20, weight: .bold)) 64 Text(dateFormatter(passedDate: transaction.date)) 65 .font(.system(.caption)) 66 .foregroundColor(colorScheme == .light ? Color.black.opacity(0.5) : Color.white.opacity(0.5)) 67 } 68 Spacer(minLength: 0) 69 Text("\(transaction.type == 0 ? "+" : "-") \(transaction.amount, specifier: "%.2f")") 70 .font(.system(size: 18, weight: .semibold)) 71 .foregroundColor(transaction.type == 0 ? Color.green : Color.red) 72 } 73 } 74 75 private func vendorIcon(name: String) -> some View { 76 return ZStack { 77 Circle() 78 .fill(client.randomColor(seed: name)) 79 .frame(width: 60, height: 60) 80 Text("\(String(name.uppercased().prefix(1)))") 81 .fontWeight(.bold) 82 .font(.system(.title)) 83 .foregroundColor(Color.white) 84 .shadow(color: colorScheme == .light ? Color.black.opacity(0.1) : Color.white.opacity(0.1), radius: 3) 85 } 86 } 87 88 private func dateFormatter(passedDate: String) -> String { 89 let formatter = DateFormatter() 90 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 91 formatter.timeZone = TimeZone(abbreviation: "UTC") 92 let date = formatter.date(from: passedDate) 93 formatter.dateFormat = "E, MMM dd" 94 return formatter.string(from: date!) 95 } 96 97 private var loading: some View { 98 List { 99 ForEach(0..<25, id:\.self) { item in 100 LoadingCell() 101 } 102 } 103 } 104} 105

Source

Github

Comments