How to Build a Chat App with SwiftUI: Part 2 (Channels)

3 min read
Matheus C.
Matheus C.
Published June 24, 2020 Updated February 17, 2022

In Part 1 of this series, we created a simple chat application for iOS and macOS using SwiftUI and Stream Chat's Swift SDK, but it only had a single channel. In this tutorial, we'll improve on it by implementing a channels screen with three features: join, create, and search channels. Although Stream Chat provides a suite of UIKit components, including those for channels, we'll use the low-level client to develop custom components with SwiftUI.

Note: This article's content is outdated.
In the meantime, check out our SwiftUI SDK, or get started with our new SwiftUI chat app tutorial.

Animation shows a channels screen. User creates then searches a channel.

If you get lost during this tutorial, or want to get the completed code for Part 1, you can check the completed project in this GitHub repo. Branch part1 has the code for Part 1.

What you need

  • iOS 13+
  • Xcode 11+
  • A Stream account
  • Followed Part 1.

Step 1: Update the Chat View

In the first part, we developed a Chat View that only displayed the "general" channel. Now we need to make it flexible to display any channel. Your ChatView.swift should look like this:

import SwiftUI
import StreamChat

struct ChatView: View {
    @StateObject var channel: ChatChannelController.ObservableObject
    
    ...
}

Instead of the hard-coded "general" channel, that code defines an initializer for ChatView that takes an id and initializes the channel object with that id. Additionally, you'll want to replace the hard-coded snippet .navigationBarTitle("General") with .navigationBarTitle(channel.id), so it displays the title correctly.

Step 2: Create the Channels View

Now, we need a Channels View with the channels the user is part of, which will look similar to the following screenshot.

Screenshot shows a simple channels screen running on the iPhone simulator

Let's create a ChannelsView.swift file and paste the following contents. We'll leave the create channel and search functions for the next steps.

import SwiftUI
import StreamChat

struct ChannelsView: View {
    @State
    var channels: [ChatChannel] = []
    @State
    var createTrigger = false
    @State
    var searchTerm = ""
    
    var body: some View {
        VStack {
            // createChannelView
            // searchView
            List(channels, id: \.self) { channel in
                NavigationLink(destination: chatView(id: channel.cid)) {
                    Text(channel.name ?? channel.cid.id)
                }
            }.onAppear(perform: loadChannels)
        }
        .navigationBarItems(trailing: 
            Button(action: { self.createTrigger = true }) { 
                Text("Create") 
            }.disabled(self.createTrigger || !self.searchTerm.isEmpty)
        )
        .navigationBarTitle("Channels")
    }
    
    func chatView(id: ChannelId) -> ChatView {
        return ChatView(
            channel: ChatClient.shared.channelController(
                for: id
            ).observableObject
        )
    }
    
    func loadChannels() {
        let filter: Filter<ChannelListFilterScope>
        
        if searchTerm.isEmpty {
            filter = .and([.in("members", values: [ChatClient.shared.currentUserId!]),
                           .equal("type", to: "messaging")])
        } else {
            filter = .and([.equal("type", to: "messaging")])
        }
        
        let controller = ChatClient.shared.channelListController(query: .init(filter: filter))
        
        controller.synchronize { error in
            if let error = error {
                print(error)
                return
            }
            
            self.channels = controller.channels
                .filter {
                    if self.searchTerm.isEmpty {
                        return true
                    } else {
                        return $0.cid.id.contains(self.searchTerm)
                    }
                }
        }
    }
}

In that snippet of code, we create a List where each item is a NavigationLink to the ChatView. The onAppear callback makes a query to the Stream service for the channels the current user is a member of, or, in case the searchTerm is not empty, we query all channels and filter out the ones that don't contain the searched string in the id. [docs]

Note we're not handling the possible errors that can result from the query. In production, it would be best if you dealt with the possible errors.

Step 3: Modify the Login View

This is a quick step: In Part 1, the Chat View was shown right after login. What we want now is to show the Channels View.

Just swap the destination where it says NavigationLink(destination: ChatView(), tag: true, selection: $success) { with ChannelsView().

Step 4: Implement channel creation

After that small tweak to the Login View, let's implement the Create Channel function. First, go back to ChannelsView.swift and uncomment the //createChannelView in the body.

Now, we need to add the following property and methods.

struct ChannelsView: View {
    ...
  
    @State
    var createChannelName = ""
    
    var createChannelView: some View {
        if(createTrigger) {
            return AnyView(HStack {
                TextField("Channel name", text: $createChannelName)
                Button(action: { try? self.createChannel() }) {
                    Text(self.createChannelName.isEmpty ? "Cancel" : "Submit")
                }
            }.padding())
        }
        
        return AnyView(EmptyView()) // TODO: Add Channel
    }
    
    func createChannel() throws {
        self.createTrigger = false
        if !self.createChannelName.isEmpty {
            let cid = ChannelId(type: .messaging, id: createChannelName)
            let controller = try ChatClient.shared.channelController(
                createChannelWithId: cid,
                name: nil,
                imageURL: nil,
                isCurrentUserMember: true,
                extraData: .defaultValue
            )
            controller.synchronize { error in
                if let error = error {
                    print(error)
                } else if let channel = controller.channel {
                    channels.append(channel)
                }
            }
        }
        self.createChannelName = ""
    }
}

When the user presses the create button on the top right, it will set the createTrigger, which activates the display of the createChannelView. This view is a horizontal stack of a TextField and a Button, which, when pressed, runs the createChannel function. That function takes the contents of the text field and creates a channel with that as the id, after that it adds the current user to the channel.

Again, to keep the tutorial short, we're not presenting the possible errors, but in production, you should display some message.

In Step 2, we already laid the grounds for the search to happen by specifying a different filter when searchTerm is not empty. Now we only need to build the UI, which will look similar to the screenshots below.

Screenshots of the channel search function

First, go to ChannelsView.swift and uncomment //searchView. Next, define the following property and function.

import SwiftUI
import StreamChatClient

struct ChannelsView: View {
    ...
  
    var searchView: some View {
        if(createTrigger) {
            return AnyView(EmptyView())
        } else {
            let binding = Binding<String>(get: {
                self.searchTerm
            }, set: {
                self.searchTerm = $0
                self.loadChannels()
            })
            
            return AnyView(HStack {
                TextField("Search channels", text: binding)
                    if !searchTerm.isEmpty {
                        Button(action: clearPressed) {
                            Text("Clear")
                        }
                    }
                }
                .padding()
                .onDisappear(perform: {
                    if !self.searchTerm.isEmpty {
                        self.clearPressed()
                    }
                })
            )
        }
    }
    
    func clearPressed() {
        self.searchTerm = ""
        self.loadChannels() 
    }
}

That code defines a horizontal stack of a TextField and a Button. When the user types something in the text field, the loadChannels function is triggered, showing the channels that contain the search term in their ids. At the same time, the clear button is displayed. When pressed, it resets the view back to the original state.

Wrapping up

Congratulations! You've built a channels screen and learned a few functions to interact with the Stream API. Let's recap those: queryChannels to display the list of channels, channel.create to create a channel, and add(users: [], to: channel) to add the user to a channel. Since this is the last part of this series, I encourage you to browse through SwiftUI's docs, Stream Chat's docs, and experiment with the project you just built.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->