
Our previous tutorial showed you Searching and Finding Users with the TwitterClone app. Since you are here, we believe you enjoyed all the previous tutorials. In this tutorial, you will learn about Messaging and DMs by integrating the Stream Chat SwiftUI SDK with this project.
Find the project demo and download the source code from GitHub.
Why Stream Chat for Messages?
The iOS/SwiftUI Chat SDK on Stream's Global Edge Network allows developers to build faster, more reliable, real-time, and fully functional chat messaging experiences similar to WhatsApp, Facebook Messenger, and Telegram. You can create SwiftUI and UIKit-based conversational experiences in no time using high-level UI components.
Stream' SwiftUI Chat SDK helps developers to build chat messaging apps with as minimal code as possible. The SDK provides several customization options as well as offline support, which makes it possible for users to browse channels and send messages when they are offline. You can easily create a chat experience with a fully custom-made UI. Get started with the UIKit-based or SwiftUI SDK by reading the SwiftUI Chat App Tutorial. To build a sample chat app, you can fetch the SwiftUI SDK from GitHub.
The Integration Process
An essential part of our implementation was allowing users to interact with each other directly through direct messages. To implement the massaging feature, we relied on Stream Chat. We were able to create the initial chat experience in under two hours. After the initial implementation, we only needed to refine it and ensure everything worked as intended.
Install The SDK in TwitterClone
The guide Build a SwiftUI Chat Messaging App demonstrate how to integrate the SwiftUI SDK with a blank SwiftUI app. You can integrate the Stream Chat SwiftUI SDK with the TwitterClone app using dependency managers such as CocoaPods, Carthage, or Swift Package Manager. Let's keep it simple by following the steps below to fetch the SDK from GitHub using SPM.
Add Stream Chat's dependency as a Swift Package to our Tuist dependencies.swift file.
12345678910var swiftPackageManagerDependencies = SwiftPackageManagerDependencies( [.remote(url: "https://github.com/GetStream/stream-chat-swiftui.git", requirement: .upToNextMajor(from: "4.0.0")), .remote(url: "https://github.com/GetStream/stream-chat-swift.git", requirement: .upToNextMajor(from: "4.0.0")), ], productTypes: [ "StreamChatSwiftUI" : .framework, "StreamChat": .framework, ... ] )
We then add the dependency on the Tuist project, in project.swift, to a target.
1234567891011121314151617181920212223242526272829303132333435let messagesTarget = Project.makeFrameworkTargets(name: messagesName, platform: .iOS, dependencies: [ .external(name: "StreamChatSwiftUI"), .external(name: "StreamChat"), .target(name: authName), .target(name: feedsName), .target(name: chatName), .target(name: uiName) ]) let chatTarget = Project.makeFrameworkTargets(name: chatName, platform: .iOS, dependencies: [ .external(name: "StreamChatSwiftUI"), .external(name: "StreamChat"), .target(name: authName), .target(name: feedsName), .target(name: uiName) ]) ... let spacesTarget = Project.makeFrameworkTargets(name: spacesName, platform: .iOS, dependencies: [ .external(name: "StreamChatSwiftUI"), .external(name: "StreamChat"), .target(name: uiName), .target(name: authName), .target(name: chatName), .external(name: "HMSSDK") ])
Notice how the dependencies are added to multiple targets. This is possible and causes no issues due to the StreamChat dependencies being declared dynamic frameworks.
We now fetch the dependency by running.
1tuist fetch
And make sure to generate the Xcode project again by running:
1tuist generate
Implementation
There are a few steps you should follow and set up the SDK to work with our TwitterClone. From the Xcode Project Navigator, open ChatModel.swift under the folders Chat -> Sources.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556// ChatModel.swift import Foundation import StreamChat import StreamChatSwiftUI import Auth import Feeds import NetworkKit @MainActor public class ChatModel: ObservableObject { // This is the `StreamChat` reference we need to add internal var streamChat: StreamChat public init() { streamChat = StreamChat(chatClient: self.chatClient) } // This is the `chatClient`, with config we need to add internal var chatClient: ChatClient = { //For the tutorial we use a hard coded api key and application group identifier var config = ChatClientConfig(apiKey: .init(TwitterCloneNetworkKit.apiKey)) config.applicationGroupIdentifier = "group.io.getstream.twitterclone.TwitterClone" // The resulting config is passed into a new `ChatClient` instance. let client = ChatClient(config: config) return client }() public func logout() { chatClient.logout(completion: {}) } // The `connectUser` function we need to add. public func connectUser(authUser: AuthUser, feedUser: FeedUser) throws { // This is a hardcoded token valid on Stream's tutorial environment. let token = try Token(rawValue: authUser.chatToken) let feedUserProfilePictureUrl = feedUser.profilePicture.flatMap { URL(string: $0) } // Call `connectUser` on our SDK to get started. chatClient.connectUser( userInfo: .init(id: authUser.userId, name: feedUser.fullname, imageURL: feedUserProfilePictureUrl), token: token ) { error in // TODO improve error handling if let error { // Some very basic error handling only logging the error. log.error("connecting the user failed \(error)") return } } } }
Here is how it works. In ChatModel.swift, we define the class ChatModel and create a streamChat instance. To access the SDK, we should add a chatClient and initialize it with an API key. A hard-coded API key is used in this tutorial, but you can obtain your API key by creating an account on Stream Dashboard.
We use the connectUser function to obtain the credentials from the user. Next, we send the user information to our backend infrastructure for authentication by calling the connectUser function on the SDK.
Go Further
To explore direct messaging further in our codebase, open the folders DirectMessages -> Sources and look at all the Swift files to learn more about the implementation.
DirectMessagesView.swift
123456789101112131415161718import SwiftUI import StreamChatSwiftUI import Chat public struct DirectMessagesView: View { public init() {} public var body: some View { ChatChannelListView(viewFactory: DemoAppFactory.shared) } } struct DirectMessagesView_Previews: PreviewProvider { static var previews: some View { DirectMessagesView() } }
NewChatView.swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243// // Copyright © 2023 Stream.io Inc. All rights reserved. // import StreamChat import StreamChatSwiftUI import SwiftUI struct NewChatView: View, KeyboardReadable { @Injected(\.fonts) var fonts @Injected(\.colors) var colors @StateObject var viewModel = NewChatViewModel() @Binding var isNewChatShown: Bool @State private var keyboardShown = false let columns = [GridItem(.adaptive(minimum: 120), spacing: 2)] var body: some View { VStack(spacing: 0) { HStack { Text("TO:") .font(fonts.footnote) .foregroundColor(Color(colors.textLowEmphasis)) VStack { if !viewModel.selectedUsers.isEmpty { LazyVGrid(columns: columns, alignment: .leading) { ForEach(viewModel.selectedUsers) { user in SelectedUserView(user: user) .onTapGesture( perform: { withAnimation { viewModel.userTapped(user) } } ) } } } SearchUsersView(viewModel: viewModel) } } .padding() if viewModel.state != .channel { CreateGroupButton(isNewChatShown: $isNewChatShown) UsersHeaderView() } if viewModel.state == .loading { VerticallyCenteredView { ProgressView() } } else if viewModel.state == .loaded { List(viewModel.chatUsers) { user in Button { withAnimation { viewModel.userTapped(user) } } label: { ChatUserView( user: user, onlineText: viewModel.onlineInfo(for: user), isSelected: viewModel.isSelected(user: user) ) .onAppear { viewModel.onChatUserAppear(user) } } } .listStyle(.plain) } else if viewModel.state == .noUsers { VerticallyCenteredView { Text("No user matches these keywords") .font(.title2) .foregroundColor(Color(colors.textLowEmphasis)) } } else if viewModel.state == .error { VerticallyCenteredView { Text("Error loading the users") .font(.title2) .foregroundColor(Color(colors.textLowEmphasis)) } } else if viewModel.state == .channel, let controller = viewModel.channelController { Divider() ChatChannelView( viewFactory: DemoAppFactory.shared, channelController: controller ) } else { Spacer() } } .navigationTitle("New Chat") .onReceive(keyboardWillChangePublisher) { visible in keyboardShown = visible } .modifier(HideKeyboardOnTapGesture(shouldAdd: keyboardShown)) } } struct SelectedUserView: View { @Injected(\.colors) var colors var user: ChatUser var body: some View { HStack { MessageAvatarView( avatarURL: user.imageURL, size: CGSize(width: 20, height: 20) ) Text(user.name ?? user.id) .lineLimit(1) .padding(.vertical, 2) .padding(.trailing) } .background(Color(colors.background1)) .cornerRadius(16) } } struct SearchUsersView: View { @StateObject var viewModel: NewChatViewModel var body: some View { HStack { TextField("Type a name", text: $viewModel.searchText) Button { if viewModel.state == .channel { withAnimation { viewModel.state = .loaded } } } label: { Image(systemName: "person.badge.plus") } } } } struct VerticallyCenteredView<Content: View>: View { var content: () -> Content var body: some View { VStack { Spacer() content() Spacer() } } } struct CreateGroupButton: View { @Injected(\.colors) var colors @Injected(\.fonts) var fonts @Binding var isNewChatShown: Bool var body: some View { NavigationLink { CreateGroupView(isNewChatShown: $isNewChatShown) } label: { HStack { Image(systemName: "person.3") .renderingMode(.template) .foregroundColor(colors.tintColor) Text("Create a group") .font(fonts.bodyBold) .foregroundColor(Color(colors.text)) Spacer() } .padding() } .isDetailLink(false) } } struct ChatUserView: View { @Injected(\.colors) var colors @Injected(\.fonts) var fonts var user: ChatUser var onlineText: String var isSelected: Bool var body: some View { HStack { LazyView( MessageAvatarView(avatarURL: user.imageURL) ) VStack(alignment: .leading, spacing: 4) { Text(user.name ?? user.id) .lineLimit(1) .font(fonts.bodyBold) Text(onlineText) .font(fonts.footnote) .foregroundColor(Color(colors.textLowEmphasis)) } Spacer() if isSelected { Image(systemName: "checkmark") .renderingMode(.template) .foregroundColor(colors.tintColor) } } } } struct UsersHeaderView: View { @Injected(\.colors) var colors @Injected(\.fonts) var fonts var title = "On the platform" var body: some View { HStack { Text(title) .padding(.horizontal) .padding(.vertical, 2) .font(fonts.body) .foregroundColor(Color(colors.textLowEmphasis)) Spacer() } .background(Color(colors.background1)) } }
Conclusion
Congratulations on completing the firth part of the TwitterClone tutorial series. Check out our documentation to learn more about our SwiftUI and UIKit-based chat messaging components. The next tutorial is about integrating the Twitter Spaces clone into the project. Head to the next tutorial to learn Conversations with Spaces.
