SwiftUI - Integrate UI without a ready backend

Photo by Mark Boss on Unsplash

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:
image.png

The row that displays a message only needs data in String format and a Bool to know if there is a new message: image.png

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 😎: image.png

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:  image.png

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: image.png

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: image.png

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, ...) image.png

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!!