Skip to content

Commit 55dcbb2

Browse files
Implemented typing indicators
2 parents 94ba265 + 717448c commit 55dcbb2

File tree

19 files changed

+376
-12
lines changed

19 files changed

+376
-12
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
The SwiftUI SDK is built on top of the [StreamChat](https://getstream.io/chat/docs/ios-swift/?language=swift) framework and it's a SwiftUI alternative to the [StreamChatUI](https://getstream.io/chat/docs/sdk/ios/) SDK. It's built completely in SwiftUI, using declarative patterns, that will be familiar to developers working with SwiftUI. The SDK includes an extensive set of performant and customizable UI components which allow you to get started quickly with little to no plumbing required.
88

9+
The complete documentation and capabilities of the SwiftUI SDK can be found [here](https://getstream.io/chat/docs/sdk/ios/swiftui/).
10+
911
## Main Features
1012

1113
- **Channel list:** Browse channels and perform actions on them.

Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ public struct DefaultChatChannelHeader: ToolbarContent {
3030

3131
public var body: some ToolbarContent {
3232
ToolbarItem(placement: .principal) {
33-
VStack {
33+
VStack(spacing: 2) {
3434
Text(channelNamer(channel, currentUserId) ?? "")
3535
.font(fonts.bodyBold)
36-
Text(channel.onlineInfoText(currentUserId: currentUserId))
37-
.font(fonts.footnote)
38-
.foregroundColor(Color(colors.textLowEmphasis))
36+
if !channel.currentlyTypingUsersFiltered(currentUserId: currentUserId).isEmpty
37+
&& utils.typingIndicatorPlacement == .navigationBar {
38+
HStack {
39+
TypingIndicatorView()
40+
SubtitleText(text: channel.typingIndicatorString(currentUserId: currentUserId))
41+
}
42+
} else {
43+
Text(channel.onlineInfoText(currentUserId: currentUserId))
44+
.font(fonts.footnote)
45+
.foregroundColor(Color(colors.textLowEmphasis))
46+
}
3947
}
4048
}
4149

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// Copyright © 2022 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import SwiftUI
6+
7+
/// Defines the placement of the typing indicator.
8+
public enum TypingIndicatorPlacement {
9+
/// Typing indicator is shown in the navigation bar.
10+
case navigationBar
11+
/// Typing indicator is shown at the bottom of the message list.
12+
case bottomOverlay
13+
}

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,13 @@ public struct ChatChannelView<Factory: ViewFactory>: View {
5959
.if(viewModel.reactionsShown, transform: { view in
6060
view.navigationBarHidden(true)
6161
})
62-
.if(!viewModel.reactionsShown && !viewModel.isMessageThread) { view in
62+
.if(viewModel.channelHeaderType == .regular) { view in
6363
view.modifier(factory.makeChannelHeaderViewModifier(for: viewModel.channel))
6464
}
65-
.if(!viewModel.reactionsShown && viewModel.isMessageThread) { view in
65+
.if(viewModel.channelHeaderType == .typingIndicator) { view in
66+
view.modifier(factory.makeChannelHeaderViewModifier(for: viewModel.channel))
67+
}
68+
.if(viewModel.channelHeaderType == .messageThread) { view in
6669
view.modifier(factory.makeMessageThreadHeaderViewModifier())
6770
}
6871

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,17 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
8686
}
8787
}
8888

89-
@Published public var reactionsShown = false
89+
@Published public var reactionsShown = false {
90+
didSet {
91+
// When reactions are shown, the navigation bar is hidden.
92+
// Check the header type and trigger an update.
93+
checkHeaderType()
94+
}
95+
}
96+
9097
@Published public var quotedMessage: ChatMessage?
9198
@Published public var editedMessage: ChatMessage?
99+
@Published public var channelHeaderType: ChannelHeaderType = .regular
92100

93101
public var channel: ChatChannel {
94102
channelController.channel!
@@ -123,6 +131,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
123131
name: UIApplication.didReceiveMemoryWarningNotification,
124132
object: nil
125133
)
134+
135+
checkHeaderType()
126136
}
127137

128138
@objc
@@ -178,6 +188,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
178188
channelController: ChatChannelController
179189
) {
180190
messages = channelController.messages
191+
checkHeaderType()
181192
}
182193

183194
public func showReactionOverlay() {
@@ -259,6 +270,32 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
259270
channelController.markRead()
260271
}
261272
}
273+
274+
private func checkHeaderType() {
275+
let type: ChannelHeaderType
276+
let typingUsers = channel.currentlyTypingUsersFiltered(
277+
currentUserId: chatClient.currentUserId
278+
)
279+
280+
if !reactionsShown && isMessageThread {
281+
type = .messageThread
282+
} else if !typingUsers.isEmpty {
283+
type = .typingIndicator
284+
} else {
285+
type = .regular
286+
}
287+
288+
if type != channelHeaderType {
289+
channelHeaderType = type
290+
} else if type == .typingIndicator {
291+
// Toolbar is not updated when new user starts typing.
292+
// Therefore, we shortly update the state to regular to trigger an update.
293+
channelHeaderType = .regular
294+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
295+
self?.channelHeaderType = .typingIndicator
296+
}
297+
}
298+
}
262299
}
263300

264301
extension ChatMessage: Identifiable {
@@ -316,3 +353,13 @@ extension ChatMessage: Identifiable {
316353
return output
317354
}
318355
}
356+
357+
/// The type of header shown in the chat channel screen.
358+
public enum ChannelHeaderType {
359+
/// The regular header showing the channel name and members.
360+
case regular
361+
/// The header shown in message threads.
362+
case messageThread
363+
/// The header shown when someone is typing.
364+
case typingIndicator
365+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import SwiftUI
77

88
struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
99
@Injected(\.utils) private var utils
10+
@Injected(\.chatClient) private var chatClient
1011

1112
var factory: Factory
1213
var channel: ChatChannel
@@ -144,6 +145,13 @@ struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
144145
if let date = currentDateString {
145146
DateIndicatorView(date: date)
146147
}
148+
149+
if !channel.currentlyTypingUsersFiltered(currentUserId: chatClient.currentUserId).isEmpty
150+
&& utils.typingIndicatorPlacement == .bottomOverlay {
151+
TypingIndicatorBottomView(
152+
typingIndicatorString: channel.typingIndicatorString(currentUserId: chatClient.currentUserId)
153+
)
154+
}
147155
}
148156
.onReceive(keyboardPublisher) { visible in
149157
if currentDateString != nil {
@@ -241,3 +249,28 @@ public struct DateIndicatorView: View {
241249
}
242250
}
243251
}
252+
253+
struct TypingIndicatorBottomView: View {
254+
@Injected(\.colors) private var colors
255+
@Injected(\.fonts) private var fonts
256+
257+
var typingIndicatorString: String
258+
259+
var body: some View {
260+
VStack {
261+
Spacer()
262+
HStack {
263+
TypingIndicatorView()
264+
Text(typingIndicatorString)
265+
.font(.footnote)
266+
.foregroundColor(Color(colors.textLowEmphasis))
267+
Spacer()
268+
}
269+
.standardPadding()
270+
.background(
271+
Color(colors.background)
272+
.opacity(0.9)
273+
)
274+
}
275+
}
276+
}

Sources/StreamChatSwiftUI/ChatChannel/Utils/ChatChannelExtensions.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Foundation
66
import StreamChat
77

88
extension ChatChannel {
9+
910
func onlineInfoText(currentUserId: String) -> String {
1011
if isDirectMessageChannel {
1112
guard let member = lastActiveMembers
@@ -27,6 +28,22 @@ extension ChatChannel {
2728
return L10n.Message.Title.group(memberCount, watcherCount)
2829
}
2930

31+
func currentlyTypingUsersFiltered(currentUserId: UserId?) -> [ChatUser] {
32+
currentlyTypingUsers.filter { user in
33+
user.id != currentUserId
34+
}
35+
}
36+
37+
func typingIndicatorString(currentUserId: UserId?) -> String {
38+
let typingUsers = currentlyTypingUsersFiltered(currentUserId: currentUserId)
39+
if let user = typingUsers.first(where: { user in user.name != nil }), let name = user.name {
40+
return L10n.MessageList.TypingIndicator.users(name, typingUsers.count - 1)
41+
} else {
42+
// If we somehow cannot fetch any user name, we simply show that `Someone is typing`
43+
return L10n.MessageList.TypingIndicator.typingUnknown
44+
}
45+
}
46+
3047
private var lastSeenDateFormatter: (Date) -> String? {
3148
DateUtils.timeAgo
3249
}

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,20 @@ extension ChatChannel: Identifiable {
9393
}
9494

9595
public var id: String {
96-
"\(cid.id)-\(lastMessageAt ?? createdAt)-\(lastActiveMembersCount)-\(mutedString)-\(unreadCount.messages)"
96+
"\(cid.id)-\(lastMessageAt ?? createdAt)-\(lastActiveMembersCount)-\(mutedString)-\(unreadCount.messages)-\(typingUsersString)"
9797
}
9898

99-
public var lastActiveMembersCount: Int {
99+
private var lastActiveMembersCount: Int {
100100
lastActiveMembers.filter { member in
101101
member.isOnline
102102
}
103103
.count
104104
}
105+
106+
private var typingUsersString: String {
107+
currentlyTypingUsers.map { user in
108+
user.id
109+
}
110+
.joined(separator: "-")
111+
}
105112
}

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct ChatChannelListItem: View {
1212
@Injected(\.colors) private var colors
1313
@Injected(\.utils) private var utils
1414
@Injected(\.images) private var images
15+
@Injected(\.chatClient) private var chatClient
1516

1617
var channel: ChatChannel
1718
var channelName: String
@@ -46,7 +47,15 @@ public struct ChatChannelListItem: View {
4647
Spacer()
4748
}
4849
} else {
49-
SubtitleText(text: subtitleText)
50+
HStack(spacing: 4) {
51+
if !channel.currentlyTypingUsersFiltered(
52+
currentUserId: chatClient.currentUserId
53+
).isEmpty {
54+
TypingIndicatorView()
55+
}
56+
SubtitleText(text: subtitleText)
57+
Spacer()
58+
}
5059
}
5160
}
5261

@@ -75,6 +84,10 @@ public struct ChatChannelListItem: View {
7584
private var subtitleText: String {
7685
if channel.isMuted {
7786
return L10n.Channel.Item.muted
87+
} else if !channel.currentlyTypingUsersFiltered(
88+
currentUserId: chatClient.currentUserId
89+
).isEmpty {
90+
return channel.typingIndicatorString(currentUserId: chatClient.currentUserId)
7891
} else if let latestMessage = channel.latestMessages.first {
7992
return "\(latestMessage.author.name ?? latestMessage.author.id): \(latestMessage.textContent ?? latestMessage.text)"
8093
} else {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// Copyright © 2022 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import SwiftUI
6+
7+
/// View shown when other users are typing.
8+
struct TypingIndicatorView: View {
9+
10+
@State private var isTyping = false
11+
12+
private let animationDuration: CGFloat = 0.75
13+
14+
var body: some View {
15+
HStack(spacing: 4) {
16+
TypingIndicatorCircle(isTyping: isTyping)
17+
.animation(
18+
.easeOut(duration: animationDuration)
19+
.repeatForever(autoreverses: true), value: isTyping
20+
)
21+
TypingIndicatorCircle(isTyping: isTyping)
22+
.animation(
23+
.easeInOut(duration: animationDuration)
24+
.repeatForever(autoreverses: true), value: isTyping
25+
)
26+
TypingIndicatorCircle(isTyping: isTyping)
27+
.animation(
28+
.easeIn(duration: animationDuration)
29+
.repeatForever(autoreverses: true), value: isTyping
30+
)
31+
}
32+
.onAppear {
33+
/// NOTE: This is needed because of a glitch when the animation is performed in a navigation bar.
34+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
35+
isTyping = true
36+
}
37+
}
38+
}
39+
}
40+
41+
/// View that represents one circle of the typing indicator view.
42+
private struct TypingIndicatorCircle: View {
43+
44+
private let circleWidth: CGFloat = 4
45+
private let circleHeight: CGFloat = 4
46+
private let yOffset: CGFloat = 1.5
47+
private let minOpacity: CGFloat = 0.1
48+
private let maxOpacity: CGFloat = 1.0
49+
50+
var isTyping: Bool
51+
52+
var body: some View {
53+
Circle()
54+
.frame(width: circleWidth, height: circleHeight)
55+
.opacity(isTyping ? maxOpacity : minOpacity)
56+
.offset(y: isTyping ? yOffset : -yOffset)
57+
}
58+
}

0 commit comments

Comments
 (0)