Building an Inbox with SwiftUI

Helder Pinhal
May 15 2020
Posted in Engineering & Technology

Experimenting with Apple's new declarative UI framework

It has been nearly a year since Apple announced SwiftUI — a new declarative UI framework. Because it's a very young framework, lacking in features when compared to UIKit, we would not recommended it in a production app. But SwiftUI is definitely a glimpse of the future and we truly believe it will become the number one choice for developers.

In this article, I will guide you through the making of an in-app message inbox using SwiftUI, exploring the navigation and list components.

Before we get started, all the code is open-source and available on GitHub, divided into three parts:

Part 1 - a static inbox

Let’s get started by downloading the boilerplate code. This includes some sample data to help you build the UI and some utility components to handle the asynchronous loading of images.

Creating the traditional cell

Our view will display some information about the notification (inbox item) namely:

  1. Title
  2. Message
  3. How long ago it has been received
  4. The status (read / unread)
  5. A colour indicator
  6. The attachment preview (if applicable)

Create a new SwiftUI view via Cmd(⌘) + N > SwiftUI View named InboxItemView.

Let’s start by setting up the InboxItem property and the Live Preview of our view.

struct InboxItemView: View {
    var inboxItem: InboxItem

    var body: some View {
        Text("Hello, World!")
    }
}

struct InboxItemView_Previews: PreviewProvider {
    static var previews: some View {
        InboxItemView(inboxItem: inboxItems[0])
    }
}

Proceed to create some auxiliary views for the indicator and attachment.

private var itemIndicator: some View {
    inboxItem.color
        .frame(width: 4, height: 40)
        .clipShape(RoundedRectangle(cornerRadius: 8))
}

private var attachmentPlaceholder: some View {
    Color.black
        .opacity(0)
        .frame(width: 80, height: 60)
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .overlay(RoundedRectangle(cornerRadius: 8).stroke(self.inboxItem.color, lineWidth: 2))
}

private var attachmentErrorPlaceholder: some View {
    Image(systemName: "xmark.icloud.fill")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .padding()
        .frame(width: 80, height: 60)
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .overlay(RoundedRectangle(cornerRadius: 8).stroke(self.inboxItem.color, lineWidth: 2))
}

Now we’re ready to setup the contents of our InboxItemView. We will create a horizontal stack with the indicator & attachment, followed by the title & message and lastly the time indicator & read status. Replace the body with the following:

var body: some View {
    HStack(alignment: .top, spacing: 0) {
        if inboxItem.attachment != nil {
            AsyncImage(
                url: URL(string: inboxItem.attachment!)!,
                cache: self.cache,
                placeholder: attachmentPlaceholder,
                errorPlaceholder: attachmentErrorPlaceholder,
                content: {
                    $0.resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 80, height: 60)
                        .clipShape(RoundedRectangle(cornerRadius: 8))
                        .overlay(RoundedRectangle(cornerRadius: 8).stroke(self.inboxItem.color, lineWidth: 2))
                }
            )
        } else {
            itemIndicator
        }

        VStack(alignment: .leading) {
            Text(inboxItem.title)
                .font(.headline)
                .lineLimit(1)
                .truncationMode(.tail)

            Text(inboxItem.message)
                .font(.body)
                .opacity(0.54)
                .lineLimit(2)
                .truncationMode(.tail)
        }
        .padding(.horizontal)

        Spacer()

        VStack {
            Text(Date(fromIso8601String: inboxItem.receivedAt)!.timeAgo)
                .font(.caption)
                .opacity(0.54)

            if !inboxItem.read {
                Text("")
                    .frame(width: 8, height: 8)
                    .background(Color.blue)
                    .clipShape(Circle())
                    .padding(.top)
            }
        }
    }
    .padding()
}

Our AsyncImage requires an image cache to which we can provide by adding @Environment(\.imageCache) var cache: ImageCache to the top of our InboxItemView struct.

If you haven’t done so, after building the project the Live Preview should reflect the final result of what we just built.

Setting up the list

Let's move the focus to our ContentView which should be the default that you get when you've created/opened the project. Here we will add a NavigationView required to setup the navigation bar, and further down the line to deal with opening inbox items, as well as the unread count of our inbox and a list with its items.

This is pretty easy to setup with SwiftUI. With just a few lines of code, it’s done.

private let unreadCount = inboxItems.filter { !$0.read }.count

var body: some View {
    NavigationView {
        VStack {
            List {
                if unreadCount > 0 {
                    Text("You have \(unreadCount) unread notifications")
                    .font(.subheadline)
                    .padding(.bottom)
                }

                ForEach (inboxItems, id: \.id) { inboxItem in
                    InboxItemView(inboxItem: inboxItem)
                        .listRowInsets(EdgeInsets())
                }
            }
        }
        .navigationBarTitle("Inbox")
    }
}

By default, a List will show the separators. For our inbox we don’t want that but currently there is no way to configure this in the list object. However we can still achieve it by modifying the appearance of the List’s underlying UITableView.

init() {
    // Remove default separators
    UITableView.appearance(whenContainedInInstancesOf: [UIHostingController<ContentView>.self]).separatorColor = .clear
}

By doing so, we’re removing the default separators for any List contained within the hosting controller of our ContentView. Therefore, should we want to keep these separators in some other screen, it’s still possible as the code above has no influence.

Although this change is not visible during Live Preview, they occur during runtime.

This is it for part 1. You should have a runnable app which shows a static inbox with some sample data.

Part 2 - inbox with remote notifications

Now let's make it real and add real messages to our inbox using Notificare. We provide a free 30-day trial for new accounts with access to all our features and add-ons.

Before we get started with the changes in our app, we need to perform several steps to start receiving push notifications. All of those are well described in our documentation and I advise you to go through it before proceeding.

Once your app is ready to handle push notifications, we can start adapting our code to use Notificare’s models and react to changes in your inbox.

Creating a new extension for NotificareDeviceInbox

In the static inbox part, we already inherited an extension that provides us with a random colour for an InboxItem. Since we’ll be using a Notificare model, we need one for it as well. The code below does just that, but bases the colour on the contents of that message to reduce the randomness of the result.

extension NotificareDeviceInbox {

    var color: Color {
        var hash = 0
        let colorConstant = 131
        let maxSafeValue = Int.max / colorConstant

        for char in self.message.unicodeScalars {
            if hash > maxSafeValue {
                hash = hash / colorConstant
            }

            hash = Int(char.value) + ((hash << 5) - hash)
        }

        let finalHash = abs(hash) % (256 * 256 * 256);
        let r = Double((finalHash & 0xFF0000) >> 16) / 255.0
        let g = Double((finalHash & 0xFF00) >> 8) / 255.0
        let b = Double((finalHash & 0xFF)) / 255.0

        return Color(red: r, green: g, blue: b)
    }
}

Updating the InboxItemView

First and foremost we need to change the type of the inboxItem property, and for our convenience let’s add an attachmentUri property.

var inboxItem: NotificareDeviceInbox

private var attachmentUri: String? {
    inboxItem.attachment?["uri"] as? String
}

Since the model is slightly different, here are some changes we need to make:

  1. Change the if statement to use the just created attachmentUri and the url parameter of the AsyncImage just below
  2. The title in the new model is nullable and for the purposes of our experiment, we’ll coalesce it to something else, eg. inboxItem.title ?? "---"
  3. Change the receivedAt and read properties to the new model’s time and opened properties, respectively
  4. Lastly, we need to update the object passed to the previewer
struct InboxItemView_Previews: PreviewProvider {
    static var previews: some View {
        let item = NotificareDeviceInbox()
        item.title = "Proin at nibh vitae dui hendrerit laoreet."
        item.message = "Ut at mi nec dolor accumsan vestibulum eget in elit. Fusce gravida urna suscipit, tincidunt dui eget, mollis orci."
        item.attachment = ["uri": "https://i.picsum.photos/id/100/160/120.jpg"]
        item.time = "2020-05-13T09:28:25.080Z"

        return InboxItemView(inboxItem: item)
    }
}

Updating the ISO8601 date formatter

The contents of the time property include milliseconds and what we have at the moment will not be able to handle it. Update the extension with the following:

extension Date {

    init?(fromIso8601String str: String) {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

        guard let date = formatter.date(from: str) else { return nil }
        self = date
    }
}

Reacting to inbox changes

Start by creating a new notification name which we will use to dispatch inbox updates.

extension Notification.Name {
    static let UpdateInbox = Notification.Name("updateInbox")
}

Now, in order to dispatch said notifications, modify your AppDelegate to implement some Notificare delegate methods. By doing so, we’ll update our UI when the inbox changes and when we receive new notifications in the foreground.

func notificarePushLib(_ library: NotificarePushLib, didLoadInbox items: [NotificareDeviceInbox]) {
    NotificationCenter.default.post(name: Notification.Name.UpdateInbox, object: nil)
}

func notificarePushLib(_ library: NotificarePushLib, didReceiveRemoteNotificationInForeground notification: NotificareNotification, withController controller: Any?) {
    NotificationCenter.default.post(name: Notification.Name.UpdateInbox, object: nil)
}

Lastly, we can modify our ContentView to listen for the dispatched notifications and update the UI.

We start by updating the state properties and also improve our UI with an empty list message.

@State private var inboxItems: [NotificareDeviceInbox] = []
@State private var unreadCount = 0
@State private var inboxLoaded = false

// ...

NavigationView {
    VStack {
        if inboxLoaded && inboxItems.isEmpty {
            Text("You have no notifications")
                .font(.headline)
        } else {
            List {
                // ...
            }
        }
    }
    .navigationBarTitle("Inbox")
}
.navigationViewStyle(StackNavigationViewStyle())

In order to receive the updates, we can modify our top-most view, the NavigationView with onReceive() and the updateInbox method to handle its events.

.onReceive(NotificationCenter.default.publisher(for: Notification.Name.UpdateInbox), perform: { _ in
    self.updateInbox()
})

// ...

private func updateInbox() {
    NotificarePushLib.shared().inboxManager.fetchInbox { (response, error) in
        guard let items = response as? [NotificareDeviceInbox] else { return }

        self.inboxItems = items
        self.unreadCount = items.filter { !$0.opened }.count
        self.inboxLoaded = true
    }
}

Handling notification opens

To wrap things up, we’ll add a tap gesture listener to our InboxItemView by doing the following:

.contentShape(Rectangle())
.onTapGesture {
    guard let navigationController = self.findNavigationViewController() else { return }

    NotificarePushLib.shared().inboxManager.openInboxItem(inboxItem) { (response, error) in
        guard error == nil, let response = response else { return }

        NotificarePushLib.shared().presentInboxItem(inboxItem, in: navigationController, withController: response)
    }
}

To open and present a notification, our SDK will need a reference to an UINavigationController and here is one of the pitfalls with SwiftUI. Given its early stages, integration with the underlying UIKit components is not so straightforward. For the time being and for the scope of our experiment we’ll try to find the UINavigationController created by our NavigationView in the view hierarchy.

private func findNavigationViewController(from controller: UIViewController? = nil) -> UINavigationController? {
    guard let controller = controller else {
        let keyWindows = UIApplication.shared.windows.filter { $0.isKeyWindow }
        guard let window = keyWindows.first, let controller = window.rootViewController else {
            return nil
        }

        return findNavigationViewController(from: controller)
    }

    switch controller {
    case let hosting as UIHostingController<ContentView>:
        guard let last = hosting.presentationController?.presentedViewController.children.last else { return nil }
        return findNavigationViewController(from: last)
    case let split as UISplitViewController:
        guard let last = split.viewControllers.last else { return nil }
        return findNavigationViewController(from: last)
    case let navigation as UINavigationController:
        return navigation
    default:
        return nil
    }
}

And that’s it folks, we now have a fully functioning inbox built with SwiftUI. 🚀

Sending push notifications via Notificare’s Dashboard

We have various comprehensive guides showcasing many of our platforms’ add-ons. You can check those out in our documentation.

Conclusion

Thank you all for reading this article! I hope you enjoyed this first experiment with SwiftUI. We’ll keep adding new functionality to our powerful inbox feature so follow the repository and stay tuned! As always, we are available via our Support Channel if you have any questions.

Keep up-to-date with the latest news