SwiftUI - Integrate UI without a ready backend
Problem: How to integrate UI with a not ready backend?
Solution: By decoupling the model returned by the API from the model that manages the UI display !
Simple example with this interface inspired by the Message app:
The row that displays a message only needs data in String
format and a Bool
to know if there is a new message:
Create the model MessageUIModel
that meets this need:
struct MessageUIModel: Identifiable {
let id: Int
let initials: String
let title: String
let message: String
let date: String
let isUnread: Bool
}
Create the view model MessagesViewModel
for this view with an array of MessageUIModel
as a @Published
property:
class MessagesViewModel: ObservableObject {
@Published var messages = [MessageUIModel]()
func fetch() {
// Wait for backend to get data from API
}
}
Integration of the view model in the MessagesView
:
struct MessagesView: View {
@StateObject var viewModel = MessagesViewModel()
var body: some View {
Text("Hello World !!")
}
}
Creation of fake data from the MessageUIModel
model with hardcoded values and MessagesViewModel
inheritance :
class MockHardcodedMessagesViewModel: MessagesViewModel {
override init() {
super.init()
fetch()
}
override func fetch() {
messages = [
.init(id: 1, initials: "AA", title: "Alexander Anderson", message: "Hello!! How are you?", date: "09:45 AM", isUnread: true),
.init(id: 2, initials: "J", title: "Jessica", message: "This a short message", date: "Yesterday", isUnread: true),
.init(id: 3, initials: "?", title: "+33 6 45 67 66 66", message: "Un message en français cool !!", date: "Tuesday", isUnread: false),
.init(id: 4, initials: "GP", title: "Georges Pitt", message: "This a very long message to test the line limit that must only displays 1 line, and not more this text must be troncated", date: "1/3/22", isUnread: true)
]
}
}
We must now display this data in the live preview using the MockHardcodedMessageViewModel
class:
struct MessagesView_Previews: PreviewProvider {
static var previews: some View {
MessagesView(viewModel: MockHardcodedMessagesViewModel())
.preferredColorScheme(.dark)
}
}
Creation of the MessagesView
which allows to display these datas:
struct MessagesView: View {
@StateObject var viewModel = MessagesViewModel()
var body: some View {
NavigationView {
ScrollView {
LazyVStack {
ForEach(viewModel.messages) { message in
HStack {
if message.isUnread {
Circle()
.frame(width: 10, height: 10)
} else {
Circle()
.fill(.clear)
.frame(width: 10, height: 10)
}
Text(message.initials)
.font(.title2)
.bold()
.frame(width: 50, height: 50)
.foregroundColor(.white)
.background(.gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(message.title)
.font(.headline)
Spacer()
Text(message.date)
.font(.callout)
.foregroundColor(.secondary)
}
Text(message.message)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.padding()
}
}
.navigationTitle("Messages")
}
}
}
You can visualize your fake datas in the live preview 😎:
Having created your UI, the back-end guys give you a payload of what the response JSON might look like:
{
"items": [
{
"id": 1,
"firstname": "Alexander",
"lastname": "Anderson",
"message": "Hello!! How are you?",
"phone_number": {
"international": "+33 6 56 67 45 66"
},
"update_date": "2022-01-31T08:45:33Z",
"unread_count": 1
}
]
}
From this information create a fake JSON that will simulate a response from the backend, create a file of type empty and name it messages.json:
Creation of the JSON that simulates a response from the API server with the datas used previously:
{
"items": [
{
"id": 1,
"firstname": "Alexander",
"lastname": "Anderson",
"message": "Hello!! How are you?",
"phone_number": {
"international": "+33 6 56 67 45 66"
},
"update_date": "2022-01-31T08:45:33Z",
"unread_count": 1
},
{
"id": 2,
"firstname": "Jessica",
"lastname": "",
"message": "This a short message",
"phone_number": {
"international": "+33 6 45 67 66 66"
},
"update_date": "2022-01-30T17:54:37Z",
"unread_count": 10
},
{
"id": 3,
"firstname": "",
"lastname": "",
"message": "Un message en français cool !!",
"phone_number": {
"international": "+33 6 45 67 99 66"
},
"update_date": "2022-01-25T17:12:33Z",
"unread_count": 0
},
{
"id": 4,
"firstname": "Georges",
"lastname": "Pitt",
"message": "This a very long message to test the line limit that must only displays 1 line",
"phone_number": {
"international": "+33 6 45 67 66 98"
},
"update_date": "2022-01-03T12:54:23Z",
"unread_count": 1
}
]
}
Creation of a model that can parse this JSON with Decodable
:
struct Root: Decodable {
let items: [Message]
struct Message: Decodable {
let id: Int
let firstname: String
let lastname: String
let message: String
let phoneNumber: PhoneNumber
let updateDate: Date
let unreadCount: Int
struct PhoneNumber: Decodable {
let international: String
}
}
}
Creating an extension
on the Bundle
type to make it easier to decode JSON from a file:
extension Bundle {
func decode<T: Decodable>(_ type: T.Type, from file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed get data from url \(url).")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Failed to decode \(file) from bundle with error: \(error)")
}
}
}
Creating fake data from this JSON with the MockApiMessagesViewModel
type:
class MockApiMessagesViewModel: MessagesViewModel {
override init() {
super.init()
fetch()
}
override func fetch() {
let apiMessages = Bundle.main.decode(Root.self, from: "messages.json").items
messages = apiMessages.map { apiMessage in
MessageUIModel(
id: apiMessage.id,
initials: "AA",
title: apiMessage.firstname,
message: apiMessage.message,
date: ISO8601DateFormatter().string(from: apiMessage.updateDate),
isUnread: false)
}
}
}
Use the fake data from the MockApiMessageViewModel
class in the live preview:
struct MessagesView_Previews: PreviewProvider {
static var previews: some View {
MessagesView(viewModel: MockHardcodedMessagesViewModel())
.preferredColorScheme(.dark)
MessagesView(viewModel: MockApiMessagesViewModel())
.preferredColorScheme(.dark)
}
}
The data displayed are not correctly formatted:
Start with the initials
property which displays the initials of the first name and last name or ? if empty :
class MessagesViewModel: ObservableObject {
...
// MARK: - Helpers
func initials(for message: Root.Message) -> String {
let firstLetter = String(message.firstname.prefix(1))
let secondLetter = String(message.lastname.prefix(1))
switch (!firstLetter.isEmpty, !secondLetter.isEmpty) {
case (true, true):
return "\(firstLetter)\(secondLetter)"
case (true, _):
return firstLetter
case (_, true):
return secondLetter
default:
return "?"
}
}
Use the function initial(for:)
when creating the model MessageUIModel
:
class MockApiMessagesViewModel: MessagesViewModel {
...
messages = apiMessages.map { apiMessage in
MessageUIModel(
id: apiMessage.id,
initials: initials(for: apiMessage),
title: apiMessage.firstname,
message: apiMessage.message,
date: ISO8601DateFormatter().string(from: apiMessage.updateDate),
isUnread: false)
}
}
}
The initials are correctly formatted:
Do the same to correctly format name, date and manage the visibility of unread message:
class MessagesViewModel: ObservableObject {
...
// MARK: - Helpers
...
func isUnread(for message: Root.Message) -> Bool {
message.unreadCount > 0
}
func formattedDate(for message: Root.Message ) -> String {
let formatter = DateFormatter()
let isToday = Calendar.current.isDateInToday(message.updateDate)
let isYesterday = Calendar.current.isDateInYesterday(message.updateDate)
let now = Date()
let oneWeekAgo = Calendar.current.startOfDay(for: now).addingTimeInterval(-604_800)
let isLessThanOneWeekAgo = (oneWeekAgo...now).contains(message.updateDate)
if isToday {
formatter.timeStyle = .short
} else if isYesterday {
return "Yesterday"
} else if isLessThanOneWeekAgo {
formatter.dateFormat = "EEEE"
return formatter.string(from: message.updateDate)
} else {
formatter.timeStyle = .none
formatter.dateStyle = .short
}
return formatter.string(from: message.updateDate)
}
func title(for message: Root.Message) -> String {
let isFirstname = !message.firstname.isEmpty
let isLastname = !message.lastname.isEmpty
switch (isFirstname, isLastname) {
case (true, true):
return "\(message.firstname) \(message.lastname)"
case (true, _):
return message.firstname
case (_, true):
return message.lastname
default:
return message.phoneNumber.international
}
}
}
Use isUnread(for:)
, formattedDate(for:)
, title(for:)
when create the MessageUIModel
:
class MockApiMessagesViewModel: MessagesViewModel {
...
messages = apiMessages.map { apiMessage in
MessageUIModel(
id: apiMessage.id,
initials: initials(for: apiMessage),
title: title(for: apiMessage),
message: apiMessage.message,
date: formattedDate(for: apiMessage),
isUnread: isUnread(for: apiMessage))
}
...
}
Now your view is displayed correctly (you may need to change the dates in the JSON to have consistent dates to display today, yesterday, ...)
You just have to wait for the backend team to finish the route to retrieve the real JSON via an API request.
You can continue to integrate the next UI screen. The backend will not stop you anymore.
That's it!!