Building an Inbox with SwiftUI
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:
- Title
- Message
- How long ago it has been received
- The status (read / unread)
- A colour indicator
- 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.
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:
- Change the if statement to use the just created
attachmentUri
and theurl
parameter of theAsyncImage
just below - 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 ?? "---"
- Change the
receivedAt
andread
properties to the new model’stime
andopened
properties, respectively - 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.