Skip to content

Commit 005568d

Browse files
Added support for customizing top reactions padding (#265)
1 parent 8a924ba commit 005568d

File tree

6 files changed

+273
-3
lines changed

6 files changed

+273
-3
lines changed

DemoAppSwiftUI/CustomChannelHeader.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ struct CustomChannelModifier: ChannelListHeaderViewModifier {
8686
message: Text("Are you sure you want to sign out?"),
8787
primaryButton: .destructive(Text("Sign out")) {
8888
withAnimation {
89-
chatClient.disconnect()
89+
chatClient.disconnect {}
9090
AppState.shared.userState = .notLoggedIn
9191
}
9292
},

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
210210
}
211211
}
212212
}
213-
.padding(.top, reactionsShown && !isMessagePinned ? 3 * paddingValue : 0)
213+
.padding(
214+
.top,
215+
reactionsShown && !isMessagePinned ? messageListConfig.messageDisplayOptions.reactionsTopPadding(message) : 0
216+
)
214217
.padding(.horizontal, messageListConfig.messagePaddings.horizontal)
215218
.padding(.bottom, showsAllInfo || isMessagePinned ? paddingValue : 2)
216219
.padding(.top, isLast ? paddingValue : 0)

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public struct MessageDisplayOptions {
9393
public let shouldAnimateReactions: Bool
9494
public let messageLinkDisplayResolver: (ChatMessage) -> [NSAttributedString.Key: Any]
9595
public let spacerWidth: (CGFloat) -> CGFloat
96+
public let reactionsTopPadding: (ChatMessage) -> CGFloat
9697

9798
public init(
9899
showAvatars: Bool = true,
@@ -108,7 +109,8 @@ public struct MessageDisplayOptions {
108109
shouldAnimateReactions: Bool = true,
109110
messageLinkDisplayResolver: @escaping (ChatMessage) -> [NSAttributedString.Key: Any] = MessageDisplayOptions
110111
.defaultLinkDisplay,
111-
spacerWidth: @escaping (CGFloat) -> CGFloat = MessageDisplayOptions.defaultSpacerWidth
112+
spacerWidth: @escaping (CGFloat) -> CGFloat = MessageDisplayOptions.defaultSpacerWidth,
113+
reactionsTopPadding: @escaping (ChatMessage) -> CGFloat = MessageDisplayOptions.defaultReactionsTopPadding
112114
) {
113115
self.showAvatars = showAvatars
114116
self.showAuthorName = showAuthorName
@@ -123,6 +125,7 @@ public struct MessageDisplayOptions {
123125
self.shouldAnimateReactions = shouldAnimateReactions
124126
self.spacerWidth = spacerWidth
125127
self.showAvatarsInGroups = showAvatarsInGroups ?? showAvatars
128+
self.reactionsTopPadding = reactionsTopPadding
126129
}
127130

128131
public func showAvatars(for channel: ChatChannel) -> Bool {
@@ -146,6 +149,10 @@ public struct MessageDisplayOptions {
146149
}
147150
}
148151
}
152+
153+
public static var defaultReactionsTopPadding: (ChatMessage) -> CGFloat {
154+
{ _ in 24 }
155+
}
149156
}
150157

151158
/// Type of message list. Currently only `messaging` is supported.

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@
315315
84E04796284A444E00BAFA17 /* EventBatcherMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BA08227E0B9C600ED20C7 /* EventBatcherMock.swift */; };
316316
84E04797284A444E00BAFA17 /* WebSocketPingControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BA08627E0BACB00ED20C7 /* WebSocketPingControllerMock.swift */; };
317317
84E04798284A444E00BAFA17 /* InternetConnectionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C94D23275794D3007FE2B9 /* InternetConnectionMock.swift */; };
318+
84E1D8262976B3F100060491 /* MessageViewMultiRowReactions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E1D8252976B3F100060491 /* MessageViewMultiRowReactions_Tests.swift */; };
318319
84E4F7CF294C69F300DD4CE3 /* MessageIdBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E4F7CE294C69F200DD4CE3 /* MessageIdBuilder.swift */; };
319320
84E57C5928103822002213C1 /* TestDataModel2.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 84E57C5328103822002213C1 /* TestDataModel2.xcdatamodeld */; };
320321
84E57C5B28103822002213C1 /* TestDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 84E57C5728103822002213C1 /* TestDataModel.xcdatamodeld */; };
@@ -721,6 +722,7 @@
721722
84DEC8E72760EABC00172876 /* ChatChannelDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelDataSource.swift; sourceTree = "<group>"; };
722723
84DEC8E92761089A00172876 /* MessageThreadHeaderViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageThreadHeaderViewModifier.swift; sourceTree = "<group>"; };
723724
84DEC8EB27611CAE00172876 /* SendInChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendInChannelView.swift; sourceTree = "<group>"; };
725+
84E1D8252976B3F100060491 /* MessageViewMultiRowReactions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewMultiRowReactions_Tests.swift; sourceTree = "<group>"; };
724726
84E4F7CE294C69F200DD4CE3 /* MessageIdBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageIdBuilder.swift; sourceTree = "<group>"; };
725727
84E57C46281037B2002213C1 /* AssertJSONEqual.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertJSONEqual.swift; sourceTree = "<group>"; };
726728
84E57C47281037B2002213C1 /* AssertResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertResult.swift; sourceTree = "<group>"; };
@@ -1458,6 +1460,7 @@
14581460
844D1D672851DE58000CCCB9 /* ChannelControllerFactory_Tests.swift */,
14591461
842F036C288E93BF00496D49 /* ChatMessage_AdjustedText_Tests.swift */,
14601462
84D419B928EAD20C00F574F9 /* ChatMessageBubbles_Tests.swift */,
1463+
84E1D8252976B3F100060491 /* MessageViewMultiRowReactions_Tests.swift */,
14611464
);
14621465
path = ChatChannel;
14631466
sourceTree = "<group>";
@@ -2061,6 +2064,7 @@
20612064
84507C98281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift in Sources */,
20622065
84C94D1727578BF3007FE2B9 /* XCTAssertEqual+Difference.swift in Sources */,
20632066
84B2B5CC28195C9300479CEE /* PinnedMessagesViewModel_Tests.swift in Sources */,
2067+
84E1D8262976B3F100060491 /* MessageViewMultiRowReactions_Tests.swift in Sources */,
20642068
84782785284A4DB500D2EE11 /* ChatClient_Mock.swift in Sources */,
20652069
84C94D0D27578BF2007FE2B9 /* AssertResult.swift in Sources */,
20662070
84E0478B284A444E00BAFA17 /* VirtualTimer.swift in Sources */,
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
//
2+
// Copyright © 2023 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import SnapshotTesting
6+
@testable import StreamChat
7+
@testable import StreamChatSwiftUI
8+
import SwiftUI
9+
import XCTest
10+
11+
final class MessageViewMultiRowReactions_Tests: StreamChatTestCase {
12+
13+
override public func setUp() {
14+
super.setUp()
15+
let reactionsTopPadding: (ChatMessage) -> CGFloat = { message in
16+
let padding: CGFloat = 8
17+
var paddingOffset: CGFloat = 0
18+
let rowSize: CGFloat = 24
19+
let chunkSize: CGFloat = 2
20+
let reactionsCount = CGFloat(message.reactionScores.count)
21+
let numberOfRows = ceil(reactionsCount / chunkSize)
22+
if numberOfRows > 1 {
23+
paddingOffset = (numberOfRows - 1) * padding
24+
}
25+
return numberOfRows * rowSize + paddingOffset
26+
}
27+
let messageDisplayOptions = MessageDisplayOptions(reactionsTopPadding: reactionsTopPadding)
28+
29+
let utils = Utils(
30+
messageListConfig: MessageListConfig(messageDisplayOptions: messageDisplayOptions)
31+
)
32+
streamChat = StreamChat(chatClient: chatClient, utils: utils)
33+
}
34+
35+
func test_messageViewMultiRowReactions_snapshot() {
36+
// Given
37+
let viewFactory = TestViewFactory.shared
38+
let message = ChatMessage.mock(
39+
text: "Test message",
40+
reactionScores: [
41+
.init(rawValue: "love"): 1,
42+
.init(rawValue: "like"): 1,
43+
.init(rawValue: "haha"): 1
44+
]
45+
)
46+
let channel = ChatChannel.mockDMChannel()
47+
48+
// When
49+
let view = MessageContainerView(
50+
factory: viewFactory,
51+
channel: channel,
52+
message: message,
53+
showsAllInfo: true,
54+
isInThread: false,
55+
isLast: true,
56+
scrolledId: .constant(nil),
57+
quotedMessage: .constant(nil),
58+
onLongPress: { _ in }
59+
)
60+
.applyDefaultSize()
61+
62+
// Then
63+
assertSnapshot(matching: view, as: .image)
64+
}
65+
}
66+
67+
class TestViewFactory: ViewFactory {
68+
69+
@Injected(\.chatClient) public var chatClient
70+
71+
private init() {}
72+
73+
public static let shared = TestViewFactory()
74+
75+
func makeMessageReactionView(
76+
message: ChatMessage,
77+
onTapGesture: @escaping () -> Void,
78+
onLongPressGesture: @escaping () -> Void
79+
) -> some View {
80+
CustomReactionsContainer(message: message, onTapGesture: onTapGesture, onLongPressGesture: onLongPressGesture)
81+
}
82+
}
83+
84+
struct CustomReactionsContainer: View {
85+
86+
@Injected(\.utils) var utils
87+
88+
let chunkSize = 2
89+
90+
let message: ChatMessage
91+
var useLargeIcons = false
92+
var onTapGesture: () -> Void
93+
var onLongPressGesture: () -> Void
94+
95+
var messageDisplayOptions: MessageDisplayOptions {
96+
utils.messageListConfig.messageDisplayOptions
97+
}
98+
99+
var body: some View {
100+
GeometryReader { reader in
101+
Color.clear
102+
.overlay(
103+
ReactionsHStack(message: message) {
104+
CustomMessageReactionView(
105+
message: message,
106+
chunkSize: chunkSize,
107+
useLargeIcons: useLargeIcons,
108+
reactions: reactions,
109+
onReactionTap: { _ in }
110+
)
111+
.onTapGesture {
112+
onTapGesture()
113+
}
114+
.onLongPressGesture {
115+
onLongPressGesture()
116+
}
117+
}
118+
.offset(
119+
x: offsetX,
120+
y: (-reader.size.height - offsetY) / 2
121+
)
122+
)
123+
}
124+
.accessibilityElement(children: .contain)
125+
.accessibilityIdentifier("ReactionsContainer")
126+
}
127+
128+
private var reactions: [MessageReactionType] {
129+
message.reactionScores.keys.filter { reactionType in
130+
(message.reactionScores[reactionType] ?? 0) > 0
131+
}
132+
.sorted(by: { lhs, rhs in
133+
lhs.rawValue < rhs.rawValue
134+
})
135+
}
136+
137+
private var offsetY: CGFloat {
138+
let topPadding = utils.messageListConfig.messageDisplayOptions.reactionsTopPadding(message)
139+
let extraPadding: CGFloat = 10
140+
return topPadding - extraPadding
141+
}
142+
143+
private var reactionsSize: CGFloat {
144+
let entrySize = 32
145+
var count = message.reactionScores.count
146+
if count > chunkSize {
147+
count = chunkSize
148+
}
149+
return CGFloat(count * entrySize)
150+
}
151+
152+
private var offsetX: CGFloat {
153+
var offset = reactionsSize / 3
154+
if message.reactionScores.count == 1 {
155+
offset = 16
156+
}
157+
return message.isSentByCurrentUser ? -offset : offset
158+
}
159+
}
160+
161+
struct ReactionsRow: Identifiable {
162+
let id: Int // the row index
163+
let reactions: [MessageReactionType]
164+
}
165+
166+
struct CustomMessageReactionView: View {
167+
168+
@Injected(\.colors) private var colors
169+
@Injected(\.images) private var images
170+
171+
let message: ChatMessage
172+
let chunkSize: Int
173+
var useLargeIcons = false
174+
var reactionsRows: [ReactionsRow] = []
175+
var onReactionTap: (MessageReactionType) -> Void
176+
177+
public init(
178+
message: ChatMessage,
179+
chunkSize: Int,
180+
useLargeIcons: Bool = false,
181+
reactions: [MessageReactionType],
182+
onReactionTap: @escaping (MessageReactionType) -> Void
183+
) {
184+
self.message = message
185+
self.useLargeIcons = useLargeIcons
186+
self.chunkSize = chunkSize
187+
// self.reactionsRows = reactions.sorted().gridByRows().enumerated().map(ReactionsRow.init(id:reactions:))
188+
self.onReactionTap = onReactionTap
189+
let chunks = reactions.chunks(chunkSize: chunkSize)
190+
for i in 0..<chunks.count {
191+
reactionsRows.append(ReactionsRow(id: i, reactions: chunks[i]))
192+
}
193+
}
194+
195+
var body: some View {
196+
197+
VStack {
198+
ForEach(reactionsRows) { reactionsRow in
199+
HStack {
200+
ForEach(reactionsRow.reactions) { reaction in
201+
if let image = iconProvider(for: reaction) {
202+
Image(uiImage: image)
203+
.resizable()
204+
.scaledToFit()
205+
.foregroundColor(color(for: reaction))
206+
.frame(width: useLargeIcons ? 25 : 20, height: useLargeIcons ? 27 : 20)
207+
.gesture(
208+
useLargeIcons ?
209+
TapGesture().onEnded {
210+
onReactionTap(reaction)
211+
} : nil
212+
)
213+
.accessibilityIdentifier("reaction-\(reaction.id)")
214+
}
215+
}
216+
}
217+
}
218+
}
219+
.padding(.all, 6)
220+
.padding(.horizontal, 4)
221+
.reactionsBubble(for: message)
222+
}
223+
224+
private func iconProvider(for reaction: MessageReactionType) -> UIImage? {
225+
if useLargeIcons {
226+
return images.availableReactions[reaction]?.largeIcon
227+
} else {
228+
return images.availableReactions[reaction]?.smallIcon
229+
}
230+
}
231+
232+
private func color(for reaction: MessageReactionType) -> Color? {
233+
var colors = colors
234+
let containsUserReaction = userReactionIDs.contains(reaction)
235+
let color = containsUserReaction ? colors.reactionCurrentUserColor : colors.reactionOtherUserColor
236+
237+
if let color = color {
238+
return Color(color)
239+
} else {
240+
return nil
241+
}
242+
}
243+
244+
private var userReactionIDs: Set<MessageReactionType> {
245+
Set(message.currentUserReactions.map(\.type))
246+
}
247+
}
248+
249+
extension Array {
250+
251+
func chunks(chunkSize: Int) -> [[Element]] {
252+
stride(from: 0, to: count, by: chunkSize).map {
253+
Array(self[$0..<Swift.min($0 + chunkSize, self.count)])
254+
}
255+
}
256+
}
Loading

0 commit comments

Comments
 (0)