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.
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.
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.
Step 5: Implement channel search
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.
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.