Skip to content

Commit 1ef823c

Browse files
Config for bottom placement of reactions (#388)
1 parent 3241ae3 commit 1ef823c

24 files changed

+642
-46
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
### ✅ Added
7+
- Config for bottom placement of reactions
8+
69
### 🐞 Fixed
710
- Video playing after being dismissed on iOS 17.1
811

DemoAppSwiftUI/WhatsAppChannelHeader.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ struct WhatsAppChannelHeader: ToolbarContent {
6666
}
6767
}
6868
}
69-
ToolbarItem(placement: .topBarTrailing) {
69+
ToolbarItem(placement: .navigationBarTrailing) {
7070
HStack {
7171
Button(action: {
7272
print("tapped on video")

Sources/StreamChatSwiftUI/ChatChannel/ChannelControllerFactory.swift

+23
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ChannelControllerFactory {
1111
@Injected(\.chatClient) var chatClient
1212

1313
private var currentChannelController: ChatChannelController?
14+
private var messageControllers = [String: ChatMessageController]()
1415

1516
/// Creates a channel controller with the provided channel id.
1617
/// - Parameter channelId: the channel's id.
@@ -23,9 +24,31 @@ class ChannelControllerFactory {
2324
currentChannelController = controller
2425
return controller
2526
}
27+
28+
/// Creates a message controller with the provided channel and message id.
29+
/// - Parameters:
30+
/// - messageId: the message's id.
31+
/// - channelId: the channel's id.
32+
/// - Returns: `ChatMessageController`
33+
func makeMessageController(
34+
for messageId: MessageId,
35+
channelId: ChannelId
36+
) -> ChatMessageController {
37+
if let messageController = messageControllers[messageId] {
38+
return messageController
39+
}
40+
let messageController = chatClient.messageController(
41+
cid: channelId,
42+
messageId: messageId
43+
)
44+
messageController.synchronize()
45+
messageControllers[messageId] = messageController
46+
return messageController
47+
}
2648

2749
/// Clears the current active channel controller.
2850
func clearCurrentController() {
2951
currentChannelController = nil
52+
messageControllers = [:]
3053
}
3154
}

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
9898
.if(viewModel.channelHeaderType == .messageThread) { view in
9999
view.modifier(factory.makeMessageThreadHeaderViewModifier())
100100
}
101+
.animation(nil)
101102

102103
factory.makeMessageComposerViewType(
103104
with: viewModel.channelController,

Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public struct VideoPlayerView: View {
5252
avPlayer.play()
5353
}
5454
.onDisappear {
55-
avPlayer.replaceCurrentItem(with: nil)
55+
avPlayer.replaceCurrentItem(with: nil)
5656
}
5757
}
5858
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//
2+
// Copyright © 2023 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
struct BottomReactionsView: View {
9+
10+
@Injected(\.chatClient) var chatClient
11+
@Injected(\.utils) var utils
12+
@Injected(\.colors) var colors
13+
14+
var showsAllInfo: Bool
15+
var reactionsPerRow: Int
16+
var onTap: () -> Void
17+
var onLongPress: () -> Void
18+
19+
@StateObject var viewModel: ReactionsOverlayViewModel
20+
21+
private let cornerRadius: CGFloat = 12
22+
private let reactionSize: CGFloat = 20
23+
24+
init(
25+
message: ChatMessage,
26+
showsAllInfo: Bool,
27+
reactionsPerRow: Int = 4,
28+
onTap: @escaping () -> Void,
29+
onLongPress: @escaping () -> Void
30+
) {
31+
self.showsAllInfo = showsAllInfo
32+
self.onTap = onTap
33+
self.reactionsPerRow = reactionsPerRow
34+
self.onLongPress = onLongPress
35+
_viewModel = StateObject(wrappedValue: ReactionsOverlayViewModel(message: message))
36+
}
37+
38+
var body: some View {
39+
if reactions.count > 3 {
40+
let numberOfRows = Int((Double(reactions.count + 1) / Double(reactionsPerRow)).rounded(.up))
41+
VStack {
42+
ForEach(0..<numberOfRows, id: \.self) { row in
43+
let start = row * reactionsPerRow
44+
let end = start + (reactionsPerRow - 1) >= reactions.count ?
45+
reactions.count - 1 : start + (reactionsPerRow - 1)
46+
let slice = end < start ? [] : Array(reactions[start...end])
47+
let isEndRow = slice.isEmpty ? true : end == (reactions.count - 1)
48+
HStack {
49+
if message.isRightAligned {
50+
Spacer()
51+
}
52+
content(for: slice, isEndRow: isEndRow)
53+
if !message.isRightAligned {
54+
Spacer()
55+
}
56+
}
57+
}
58+
}
59+
} else {
60+
HStack {
61+
content(for: reactions)
62+
}
63+
.offset(y: -2)
64+
}
65+
}
66+
67+
private func content(for reactions: [MessageReactionType], isEndRow: Bool = true) -> some View {
68+
Group {
69+
ForEach(reactions) { reaction in
70+
if let image = ReactionsIconProvider.icon(for: reaction, useLargeIcons: false) {
71+
HStack(spacing: 4) {
72+
ReactionIcon(
73+
icon: image,
74+
color: ReactionsIconProvider.color(
75+
for: reaction,
76+
userReactionIDs: userReactionIDs
77+
)
78+
)
79+
.frame(width: reactionSize, height: reactionSize)
80+
Text("\(count(for: reaction))")
81+
}
82+
.animation(nil)
83+
.padding(.all, 8)
84+
.background(Color(colors.background1))
85+
.modifier(
86+
BubbleModifier(
87+
corners: corners(for: reaction, in: reactions, isEndRow: isEndRow),
88+
backgroundColors: [Color(colors.background1)],
89+
cornerRadius: cornerRadius
90+
)
91+
)
92+
.onTapGesture {
93+
viewModel.reactionTapped(reaction)
94+
}
95+
.onLongPressGesture {
96+
onLongPress()
97+
}
98+
}
99+
}
100+
101+
if isEndRow && reactions.count < reactionsPerRow {
102+
Button(
103+
action: onTap,
104+
label: {
105+
Image(systemName: "face.smiling.inverse")
106+
.overlay(
107+
TopRightView {
108+
Image(systemName: "plus")
109+
.resizable()
110+
.aspectRatio(contentMode: .fit)
111+
.frame(width: 6)
112+
.padding(.all, 2)
113+
.background(Color(colors.background1))
114+
.clipShape(Circle())
115+
.offset(x: 4, y: -3)
116+
}
117+
)
118+
.padding(.all, 8)
119+
.padding(.horizontal, 2)
120+
.modifier(
121+
BubbleModifier(
122+
corners: cornersForAddReactionButton,
123+
backgroundColors: [Color(colors.background1)],
124+
cornerRadius: cornerRadius
125+
)
126+
)
127+
}
128+
)
129+
}
130+
}
131+
}
132+
133+
private var message: ChatMessage {
134+
viewModel.message
135+
}
136+
137+
private var reactions: [MessageReactionType] {
138+
viewModel.reactions
139+
}
140+
141+
private var cornersForAddReactionButton: UIRectCorner {
142+
(message.isSentByCurrentUser && showsAllInfo) ?
143+
[.bottomLeft, .bottomRight, .topLeft] : .allCorners
144+
}
145+
146+
private func corners(
147+
for reaction: MessageReactionType,
148+
in reactions: [MessageReactionType],
149+
isEndRow: Bool
150+
) -> UIRectCorner {
151+
if message.isSentByCurrentUser || reaction != reactions.first || !showsAllInfo {
152+
return .allCorners
153+
}
154+
if isEndRow {
155+
return [.bottomLeft, .bottomRight, .topRight]
156+
} else {
157+
return .allCorners
158+
}
159+
}
160+
161+
private var userReactionIDs: Set<MessageReactionType> {
162+
Set(message.currentUserReactions.map(\.type))
163+
}
164+
165+
private func count(for reaction: MessageReactionType) -> Int {
166+
message.reactionScores[reaction] ?? 0
167+
}
168+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift

+52-5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
2828
@State private var frame: CGRect = .zero
2929
@State private var computeFrame = false
3030
@State private var offsetX: CGFloat = 0
31+
@State private var offsetYAvatar: CGFloat = 0
3132
@GestureState private var offset: CGSize = .zero
3233

3334
private let replyThreshold: CGFloat = 60
@@ -70,14 +71,16 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
7071
for: utils.messageCachingUtils.authorInfo(from: message)
7172
)
7273
.opacity(showsAllInfo ? 1 : 0)
74+
.offset(y: bottomReactionsShown ? offsetYAvatar : 0)
75+
.animation(nil)
7376
}
7477
}
7578

7679
VStack(alignment: message.isRightAligned ? .trailing : .leading) {
7780
if isMessagePinned {
7881
MessagePinDetailsView(
7982
message: message,
80-
reactionsShown: reactionsShown
83+
reactionsShown: topReactionsShown
8184
)
8285
}
8386

@@ -90,7 +93,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
9093
)
9194
.overlay(
9295
ZStack {
93-
reactionsShown ?
96+
topReactionsShown ?
9497
factory.makeMessageReactionView(
9598
message: message,
9699
onTapGesture: {
@@ -172,6 +175,29 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
172175
.accessibilityElement(children: .contain)
173176
.accessibility(identifier: "MessageRepliesView")
174177
}
178+
179+
if bottomReactionsShown {
180+
factory.makeBottomReactionsView(message: message, showsAllInfo: showsAllInfo) {
181+
handleGestureForMessage(
182+
showsMessageActions: false,
183+
showsBottomContainer: false
184+
)
185+
} onLongPress: {
186+
handleGestureForMessage(showsMessageActions: false)
187+
}
188+
.background(
189+
GeometryReader { proxy in
190+
let frame = proxy.frame(in: .local)
191+
let height = frame.height
192+
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
193+
}
194+
)
195+
.onPreferenceChange(HeightPreferenceKey.self) { value in
196+
if value != 0 {
197+
self.offsetYAvatar = -(value ?? 0)
198+
}
199+
}
200+
}
175201

176202
if showsAllInfo && !message.isDeleted {
177203
if message.isSentByCurrentUser && channel.config.readEventsEnabled {
@@ -210,7 +236,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
210236
}
211237
.padding(
212238
.top,
213-
reactionsShown && !isMessagePinned ? messageListConfig.messageDisplayOptions.reactionsTopPadding(message) : 0
239+
topReactionsShown && !isMessagePinned ? messageListConfig.messageDisplayOptions.reactionsTopPadding(message) : 0
214240
)
215241
.padding(.horizontal, messageListConfig.messagePaddings.horizontal)
216242
.padding(.bottom, showsAllInfo || isMessagePinned ? paddingValue : 2)
@@ -247,6 +273,20 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
247273
messageListConfig.messageDisplayOptions.spacerWidth(width ?? 0)
248274
}
249275

276+
private var topReactionsShown: Bool {
277+
if messageListConfig.messageDisplayOptions.reactionsPlacement == .bottom {
278+
return false
279+
}
280+
return reactionsShown
281+
}
282+
283+
private var bottomReactionsShown: Bool {
284+
if messageListConfig.messageDisplayOptions.reactionsPlacement == .top {
285+
return false
286+
}
287+
return reactionsShown
288+
}
289+
250290
private var reactionsShown: Bool {
251291
!message.reactionScores.isEmpty
252292
&& !message.isDeleted
@@ -289,7 +329,10 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
289329
}
290330
}
291331

292-
private func handleGestureForMessage(showsMessageActions: Bool) {
332+
private func handleGestureForMessage(
333+
showsMessageActions: Bool,
334+
showsBottomContainer: Bool = true
335+
) {
293336
computeFrame = true
294337
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
295338
computeFrame = false
@@ -300,7 +343,8 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
300343
frame: frame,
301344
contentWidth: contentWidth,
302345
isFirst: showsAllInfo,
303-
showsMessageActions: showsMessageActions
346+
showsMessageActions: showsMessageActions,
347+
showsBottomContainer: showsBottomContainer
304348
)
305349
)
306350
}
@@ -331,6 +375,7 @@ public struct MessageDisplayInfo {
331375
public let contentWidth: CGFloat
332376
public let isFirst: Bool
333377
public var showsMessageActions: Bool = true
378+
public var showsBottomContainer: Bool = true
334379
public var keyboardWasShown: Bool = false
335380

336381
public init(
@@ -339,6 +384,7 @@ public struct MessageDisplayInfo {
339384
contentWidth: CGFloat,
340385
isFirst: Bool,
341386
showsMessageActions: Bool = true,
387+
showsBottomContainer: Bool = true,
342388
keyboardWasShown: Bool = false
343389
) {
344390
self.message = message
@@ -347,5 +393,6 @@ public struct MessageDisplayInfo {
347393
self.isFirst = isFirst
348394
self.showsMessageActions = showsMessageActions
349395
self.keyboardWasShown = keyboardWasShown
396+
self.showsBottomContainer = showsBottomContainer
350397
}
351398
}

0 commit comments

Comments
 (0)