Tutorials

Make a message centre with an Unflow Space.

As your app grows, you're going to find yourself needing a way to message your users. Pushes are a great way to handle this ( and super easy to add with Unflow ), but sometimes you need to go a little further. One way to do this is adding a notification centre to your app that will allow users to re-visit old notifications, and even recieve them in app without pushes.

Lets see how easy it is to build a notification centre using an Unflow space. We'll build a centre that has a red dot to show there's new messages, store if a user has seen one, and then allow them to delete one too.

We've recorded a quick walkthrough if you'd prefer to watch a video instead.

Setup

Unflow is going to handle the entire content distribution side of things for us - we'll just need to install it and add a super simple UI over the top.

Lets start by creating a space on our Unflow dashbaord. A space is a way to group content, and it can represent a physcial place in your app, or just a neat way to organise your content. In this case, we're making one thats dedicated to messaging, and will only ever be used for that.

01

Select the little plus next to Spaces, and then give it a name. In this case, we'll go for "Message Centre". If you were going to use our built-in UI, you could select your preferred design here, but as we're building our own we'll leave it as standard.

02

We'll land in our empty space ready to add content.

03

For our first piece to get us started, we're just going to use a gorgeous story template. Tap add content, then gallery, then the "Daily Tips" story.

04

We'll land in the delivery editor. This is where we configure how the content will be sent to the user, and how it'll look. If you're doing custom UI like we are, then you'll get to use this data to build your own view.

Whilst we're in here, its worth pointing out some of the more powerful options available to us. You can edit the audience so that only certain people see it ( think people who aren't subscribed seeing a promo ), and even send a push notification once this screen goes live.

05

Lets tap the content option at the top so we'll get to see our gorgeous story.

06

This default story has four pages that will show for 5 seconds each, and they all have a button at the bottom that links to Unflow.

This can be configured however you like, removing buttons, changing where they go or using deeplinks, changing the backgrounds to videos, and more. There's tonnes of possibilties, but we're going to leave this as is so we can see it inside our app.

Tap the set live button, then we'll get to coding. If you're a product owner, here's where you'll hand over to your dev. We promise it'll be super quick.

07

The code

We're presuming you have the SDK setup and initialised at this point. If you don't, the installation guide is here.

Our guide today is written in Swift & SwiftUI, but you can apply this exact same approach to any platform.

The SDK needs to be initialised, you'll need to set a user ID, and then you'll need to call sync. Here's what that code looks like in our demo app.

@main
struct moneyflowApp: App {
    private let unflowClient: UnflowSDK

    init() {
        // Make sure you add your API key.
        unflowClient = UnflowSDK.initialize(
            config: UnflowSDK.Config(
                apiKey: "",
                enableLogging: true // Disable in release builds.
            )
        )
        // Be sure to set your userId here.
        unflowClient.setUserId(userId: "seth-flurry")
        unflowClient.sync()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Lets start off by creating a model that can managae our messages. We'll need it to get the messages from Unflow, and keep track of what's been seen and what the user has deleted.

Because Unflow provides us a publisher ( or flow ) of openers, this will actually be super easy to do.

The initial object is super small, and we'll just inject in user defualts and the SDK.

import Foundation
import UnflowUI
import Combine

class MessagesManager: ObservableObject {
    private let unflowClient: UnflowSDK
    private let userDefaults: UserDefaults

    init(unflowClient: UnflowSDK = .client, userDefaults: UserDefaults = .standard) {
        self.unflowClient = unflowClient
        self.userDefaults = userDefaults
    }
}

We're going to need a wrapper for our unflow openers so we can track if they're seen or not, lets call this Message

struct Message {
    let opener: Opener
    let seen: Bool
}

To fetch our content from Unflow, we'll need to take advantage of that publisher, and use the space we setup. The openers function lets us provide a key to pick which space the content comes from, so all we have to do is call that and tell it we want our message centre content,

unflowClient.openers(id: "message-centre").map { [weak self] openers in
    // ...
}
.store(in: &cancellables)

If you don't know your space key, you can grab it by opening your space on the dashboard, tapping edit, then add to app.

08 09

Next, we'll need to store these, wrap them to our custom type, and expose them to the rest of the app somehow. We'll do that by storing the openers inside our map, then returning a set of mapped openers to our Message type, which we then also store, but only after switching back to the main thread.

class MessagesManager: ObservableObject {
    private let unflowClient: UnflowSDK
    private let userDefaults: UserDefaults

    private var cancellables: Set<AnyCancellable> = []
    private var openers: [Opener] = []

    @Published var messages: [Message] = []

    init(unflowClient: UnflowSDK = .client, userDefaults: UserDefaults = .standard) {
        self.unflowClient = unflowClient
        self.userDefaults = userDefaults
        unflowClient.openers(id: Constants.spaceKey).map { [weak self] openers in
            self?.openers = openers
            return self?.mapOpeners(openers: openers) ?? []
        }
        .receive(on: DispatchQueue.main)
        .sink(receiveValue: { [weak self] messages in
            self?.messages = messages
        })
        .store(in: &cancellables)
    }
}

private extension MessagesManager {
    func mapOpeners(openers: [Opener]) -> [Message] {
        openers
            .map({
                .init(opener: $0, seen: seenOpeners.contains($0.id))
            })
    }
}

// MARK: - Constants
private extension MessagesManager {
    enum Constants {
        static let spaceKey = "message-centre"
    }
}

Next, lets support user interaction. We need a way to mark a message as deleted or seen, so lets add them now with two simple functions.

class MessagesManager: ObservableObject {
    private let userDefaults: UserDefaults
    ...
    private var seenOpeners: [Int] = []
    private var deletedOpeners: [Int] = []
    
    ...

    // MARK: - User Interaction
    func markAsSeen(id: Int) {
        seenOpeners.append(id)
        userDefaults.set(seenOpeners, forKey: Constants.UserDefaults.seenOpeners)
        Task {
            await refreshMessages()
        }
    }

    func markAsDeleted(id: Int) {
        deletedOpeners.append(id)
        userDefaults.set(deletedOpeners, forKey: Constants.UserDefaults.deletedOpeners)
        Task {
            await refreshMessages()
        }
    }

    @MainActor
    func refreshMessages() {
        messages = self.mapOpeners(openers: openers)
    }
}

// MARK: - Constants
private extension MessagesManager {
    enum Constants {
        static let spaceKey = "message-centre"
        enum UserDefaults {
            static let seenOpeners = "com.unflow.samples.Notification-Centre.seenOpeners"
            static let deletedOpeners = "com.unflow.samples.Notification-Centre.deletedOpeners"
        }
    }
}

We store the openers that have been seen/deleted, back those up to user defaults, then refresh our messages every time we get a change by calling our map openers function. In order to make sure that our openers are filtered, we need to pay attention to seen/deleted inside of it, so lets change that next.

func mapOpeners(openers: [Opener]) -> [Message] {
    openers
        .filter({ !deletedOpeners.contains($0.id) })
        .map({
            .init(opener: $0, seen: seenOpeners.contains($0.id))
        })
}

We're filtering out the deleted ones, and then working out if it was seen or not by simply checking the array.

Thats all we need for our backing code - lets build a little UI to go on top.

First, we need a nice way to display our messages. Here's an example of using the underlying opener to display something that looks similar to our default banner.

struct MessageRowView: View {
    let message: Message

    var body: some View {
        HStack {
            AsyncImage(url: URL(string: message.opener.imageURL ?? "")) { image in
                image.resizable(resizingMode: .stretch)
            } placeholder: {
                Color.gray.opacity(0.5)
            }
            .aspectRatio(1.0, contentMode: .fit)
            .frame(maxWidth: 66, alignment: .top)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            VStack(alignment: .leading, spacing: 4) {
                Text(message.opener.title)
                    .font(.subheadline.weight(.semibold))
                    .foregroundColor(.primary)
                Text(message.opener.subtitle ?? "")
                    .font(.subheadline.weight(.medium))
                    .foregroundColor(.primary)
                    .opacity(0.8)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .fixedSize(horizontal: false, vertical: true)
        .listRowBackground(message.seen ? Color(uiColor: .systemBackground).opacity(0.5) : Color(uiColor: .systemBackground))
        .animation(.easeInOut, value: message.seen)
    }
}

We've got some basic functionality to change the background to a dimmer one if the notification has allready been seen, which will help it stand out in a list. You could go a little further here by showing a bright accent color on non-seen pushes, but thats up to you.

Now lets add this to a list, and consume the messages we just setup.

To do this we need to add a reference to our MessagesManager as an observed object, then iterate over the messages it gives us.

We also need to make sure we have a reference to the SDK so we can open the screens a user taps on, by calling openScreen.

import SwiftUI
import UnflowUI

struct MessageCentre: View {
    private let unflowClient: UnflowSDK = .client
    @ObservedObject var messagesManager: MessagesManager

    var body: some View {
        List {
            ForEach(messagesManager.messages, id: \.opener.id) { message in
                Button {
                    try? unflowClient.openScreen(withID: message.opener.id)
                    messagesManager.markAsSeen(id: message.opener.id)
                } label: {
                    MessageRowView(message: message)
                }
                .listRowBackground(message.seen ? Color(uiColor: .systemBackground).opacity(0.3) : Color(uiColor: .systemBackground)) // Duplicated as labels can't set backgrounds for their containers
            }
        }
        .navigationTitle("Messages")
    }
}

This can be pushed a little further by adding swipe actions that allow a user to mark as read or deleted.

import SwiftUI
import UnflowUI

struct MessageCentre: View {
    private let unflowClient: UnflowSDK = .client
    @ObservedObject var messagesManager: MessagesManager

    var body: some View {
        List {
            ForEach(messagesManager.messages, id: \.opener.id) { message in
                Button {
                    try? unflowClient.openScreen(withID: message.opener.id)
                    messagesManager.markAsSeen(id: message.opener.id)
                } label: {
                    MessageRowView(message: message)
                }
                .swipeActions {
                    Button(role: .destructive, action: {
                        messagesManager.markAsDeleted(id: message.opener.id)
                    }, label: {
                        Image(systemName: "trash")
                            .font(.subheadline.weight(.semibold))
                    })
                    Button(role: .none, action: {
                        messagesManager.markAsSeen(id: message.opener.id)
                    }, label: {
                        Image(systemName: "eyeglasses")
                            .font(.subheadline.weight(.semibold))
                    })
                    .tint(Color.blue)
                }
            }
        }
        .navigationTitle("Messages")
    }
}

There's no new code here - we're just calling the functions we made in our manager.

Lets work out the best way to show this to our users.

A common pattern is to have a little bell in the navigation bar that shows a red dot if there's some new messages. Because thats so common, we'll follow that for this guide.

If we consider our homepage, there's already a little room up there for us to put this in.

10

The first step is to make the bell and support a little red dot that tells the user there's new content.

struct MessageCentreButton: View {
    @ObservedObject var messagesManager: MessagesManager
    var onSelect: () -> ()

    var hasUnSeenMessages: Bool {
        !messagesManager.messages.isEmpty && messagesManager.messages.contains(where: { !$0.seen })
    }

    var body: some View {
        Button(action: {
            onSelect()
        }, label: {
            Image(systemName: "bell.fill")
                .font(.subheadline.weight(.bold))
                .overlay(alignment: .topTrailing, content: {
                    Circle()
                        .foregroundColor(.red)
                        .frame(width: 8, alignment: .topTrailing)
                        .offset(x: -2, y: 0) // Offset slightly for a nice effect
                        .opacity(hasUnSeenMessages ? 1 : 0)
                })
                .foregroundColor(.primary)
                .animation(.easeInOut, value: messagesManager.messages.count)
        })
    }
}

This button is super simple and re-usable in different areas of our app. It reads from the same message manager to determine if it should show the dot or not, and has a callback for when it gets tapped.

Now lets consume this button in our app. This will likely be a little different for you, but the toolbar code should be exactly the same.

The first step is to add our message manager, this time as a StateObject as this view owns it. If you want to use this in a few places inside your app, put it at the highest level. We then need to add a toolbar, then add a sheet that shows once that button is pressed.

import SwiftUI
import UnflowUI

struct HomeView: View {
    @StateObject var messageManager = MessagesManager()
    @State var showMessages: Bool = false

    var body: some View {
        NavigationView {
            content
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing, content: {
                        MessageCentreButton(messagesManager: messageManager, onSelect: { showMessages = true })
                    })
                }
                .sheet(isPresented: $showMessages) {
                    NavigationView {
                        MessageCentre(messagesManager: messageManager)
                    }
                }
        }
    }

    ...
}

If you run your app now, you'll see the little red dot fade in as Unflow syncronises with the server. Tap the bell to see the messages view in action.

11

You get a gorgeous list with our opener content, and we can easily tap to open, swipe to delete, or swipe to mark as read.

If you set more content live in your message centre space, you'll find it shows up here as soon as it's live and the app has called sync.

Here's what it looks like with a message thats been seen, and one that hasn't.

12


Thats all for today. We've got a lovely looking message centre setup, and we can expand on that by adding an even more custom look, and segmenting our content on the dashboard. A great next step would be to setup push notifications so that your users can recieve these messages as pushes too.

If you've got any questions, please reach out to us on our live chat available down below.