Skip to content

Commit 75d3a4f

Browse files
Added slot for customizing reactions (#243)
1 parent f72a382 commit 75d3a4f

File tree

10 files changed

+205
-43
lines changed

10 files changed

+205
-43
lines changed

Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsHelperViews.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
import StreamChat
66
import SwiftUI
77

8-
struct ReactionsHStack<Content: View>: View {
8+
public struct ReactionsHStack<Content: View>: View {
99
var message: ChatMessage
1010
var content: () -> Content
1111

12-
var body: some View {
12+
public init(message: ChatMessage, content: @escaping () -> Content) {
13+
self.message = message
14+
self.content = content
15+
}
16+
17+
public var body: some View {
1318
HStack {
1419
if !message.isSentByCurrentUser {
1520
Spacer()

Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayContainer.swift

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ struct ReactionsOverlayContainer: View {
3737
Spacer()
3838
}
3939
.offset(
40-
x: offsetX,
40+
x: message.reactionOffsetX(
41+
for: contentRect,
42+
reactionsSize: reactionsSize
43+
),
4144
y: -20
4245
)
4346
}
@@ -54,9 +57,16 @@ struct ReactionsOverlayContainer: View {
5457
let entrySize = 28
5558
return CGFloat(reactions.count * entrySize)
5659
}
60+
}
61+
62+
public extension ChatMessage {
5763

58-
private var offsetX: CGFloat {
59-
if message.isSentByCurrentUser {
64+
func reactionOffsetX(
65+
for contentRect: CGRect,
66+
availableWidth: CGFloat = UIScreen.main.bounds.width,
67+
reactionsSize: CGFloat
68+
) -> CGFloat {
69+
if isSentByCurrentUser {
6070
var originX = contentRect.origin.x - reactionsSize / 2
6171
let total = originX + reactionsSize
6272
if total > availableWidth {
@@ -72,13 +82,9 @@ struct ReactionsOverlayContainer: View {
7282
return contentRect.origin.x - originX
7383
}
7484
}
75-
76-
private var availableWidth: CGFloat {
77-
UIScreen.main.bounds.width
78-
}
7985
}
8086

81-
struct ReactionsAnimatableView: View {
87+
public struct ReactionsAnimatableView: View {
8288
@Injected(\.colors) private var colors
8389
@Injected(\.images) private var images
8490

@@ -89,7 +95,7 @@ struct ReactionsAnimatableView: View {
8995

9096
@State var animationStates: [CGFloat]
9197

92-
init(
98+
public init(
9399
message: ChatMessage,
94100
useLargeIcons: Bool = false,
95101
reactions: [MessageReactionType],
@@ -104,45 +110,84 @@ struct ReactionsAnimatableView: View {
104110
)
105111
}
106112

107-
var body: some View {
113+
public var body: some View {
108114
HStack {
109115
ForEach(reactions) { reaction in
110-
if let image = iconProvider(for: reaction) {
111-
Button {
112-
onReactionTap(reaction)
113-
} label: {
114-
Image(uiImage: image)
115-
.resizable()
116-
.scaledToFit()
117-
.foregroundColor(color(for: reaction))
118-
.frame(width: useLargeIcons ? 25 : 20, height: useLargeIcons ? 27 : 20)
119-
}
120-
.background(reactionSelectedBackgroundColor(for: reaction).cornerRadius(8))
121-
.scaleEffect(index(for: reaction) != nil ? animationStates[index(for: reaction)!] : 1)
122-
.onAppear {
123-
guard let index = index(for: reaction) else {
124-
return
125-
}
126-
127-
withAnimation(
128-
.interpolatingSpring(
129-
stiffness: 170,
130-
damping: 8
131-
)
132-
.delay(0.1 * CGFloat(index + 1))
133-
) {
134-
animationStates[index] = 1
135-
}
136-
}
137-
.accessibilityElement(children: .contain)
138-
.accessibilityIdentifier("reaction-\(reaction.rawValue)")
139-
}
116+
ReactionAnimatableView(
117+
message: message,
118+
reaction: reaction,
119+
useLargeIcons: useLargeIcons,
120+
reactions: reactions,
121+
animationStates: $animationStates,
122+
onReactionTap: onReactionTap
123+
)
140124
}
141125
}
142126
.padding(.all, 6)
143127
.padding(.horizontal, 4)
144128
.reactionsBubble(for: message, background: colors.background8)
145129
}
130+
}
131+
132+
public struct ReactionAnimatableView: View {
133+
@Injected(\.colors) private var colors
134+
@Injected(\.images) private var images
135+
136+
let message: ChatMessage
137+
let reaction: MessageReactionType
138+
var useLargeIcons = false
139+
var reactions: [MessageReactionType]
140+
@Binding var animationStates: [CGFloat]
141+
var onReactionTap: (MessageReactionType) -> Void
142+
143+
public init(
144+
message: ChatMessage,
145+
reaction: MessageReactionType,
146+
useLargeIcons: Bool = false,
147+
reactions: [MessageReactionType],
148+
animationStates: Binding<[CGFloat]>,
149+
onReactionTap: @escaping (MessageReactionType) -> Void
150+
) {
151+
self.message = message
152+
self.reaction = reaction
153+
self.useLargeIcons = useLargeIcons
154+
self.reactions = reactions
155+
_animationStates = animationStates
156+
self.onReactionTap = onReactionTap
157+
}
158+
159+
public var body: some View {
160+
if let image = iconProvider(for: reaction) {
161+
Button {
162+
onReactionTap(reaction)
163+
} label: {
164+
Image(uiImage: image)
165+
.resizable()
166+
.scaledToFit()
167+
.foregroundColor(color(for: reaction))
168+
.frame(width: useLargeIcons ? 25 : 20, height: useLargeIcons ? 27 : 20)
169+
}
170+
.background(reactionSelectedBackgroundColor(for: reaction).cornerRadius(8))
171+
.scaleEffect(index(for: reaction) != nil ? animationStates[index(for: reaction)!] : 1)
172+
.onAppear {
173+
guard let index = index(for: reaction) else {
174+
return
175+
}
176+
177+
withAnimation(
178+
.interpolatingSpring(
179+
stiffness: 170,
180+
damping: 8
181+
)
182+
.delay(0.1 * CGFloat(index + 1))
183+
) {
184+
animationStates[index] = 1
185+
}
186+
}
187+
.accessibilityElement(children: .contain)
188+
.accessibilityIdentifier("reaction-\(reaction.rawValue)")
189+
}
190+
}
146191

147192
private func reactionSelectedBackgroundColor(for reaction: MessageReactionType) -> Color? {
148193
var colors = colors

Sources/StreamChatSwiftUI/ChatChannel/Reactions/ReactionsOverlayView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public struct ReactionsOverlayView<Factory: ViewFactory>: View {
109109
)
110110
.overlay(
111111
channel.config.reactionsEnabled ?
112-
ReactionsOverlayContainer(
112+
factory.makeReactionsContentView(
113113
message: viewModel.message,
114114
contentRect: messageDisplayInfo.frame,
115115
onReactionTap: { reaction in

Sources/StreamChatSwiftUI/DefaultViewFactory.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,18 @@ extension ViewFactory {
750750
)
751751
}
752752

753+
public func makeReactionsContentView(
754+
message: ChatMessage,
755+
contentRect: CGRect,
756+
onReactionTap: @escaping (MessageReactionType) -> Void
757+
) -> some View {
758+
ReactionsOverlayContainer(
759+
message: message,
760+
contentRect: contentRect,
761+
onReactionTap: onReactionTap
762+
)
763+
}
764+
753765
public func makeReactionsBackgroundView(
754766
currentSnapshot: UIImage,
755767
popInAnimationInProgress: Bool

Sources/StreamChatSwiftUI/ViewFactory.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,13 @@ public protocol ViewFactory: AnyObject {
752752
popInAnimationInProgress: Bool
753753
) -> ReactionsBackground
754754

755+
associatedtype ReactionsContentView: View
756+
func makeReactionsContentView(
757+
message: ChatMessage,
758+
contentRect: CGRect,
759+
onReactionTap: @escaping (MessageReactionType) -> Void
760+
) -> ReactionsContentView
761+
755762
associatedtype QuotedMessageHeaderViewType: View
756763
/// Creates the quoted message header view in the composer.
757764
/// - Parameters:

StreamChatSwiftUITests/Tests/ChatChannel/ReactionsOverlayView_Tests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,84 @@ class ReactionsOverlayView_Tests: StreamChatTestCase {
147147
// Then
148148
assertSnapshot(matching: view, as: .image)
149149
}
150+
151+
func test_reactionAnimatableView_snapshot() {
152+
// Given
153+
let message = ChatMessage.mock(text: "Test message")
154+
let reactions: [MessageReactionType] = [.init(rawValue: "love"), .init(rawValue: "like")]
155+
156+
// When
157+
let view = ReactionAnimatableView(
158+
message: message,
159+
reaction: .init(rawValue: "love"),
160+
reactions: reactions,
161+
animationStates: .constant([1.0, 1.0]),
162+
onReactionTap: { _ in }
163+
)
164+
.frame(width: 24, height: 24)
165+
166+
// Then
167+
assertSnapshot(matching: view, as: .image)
168+
}
169+
170+
func test_reactionsOverlayContainer_snapshot() {
171+
// Given
172+
let message = ChatMessage.mock(text: "Test message")
173+
174+
// When
175+
let view = ReactionsOverlayContainer(
176+
message: message,
177+
contentRect: .init(x: -60, y: 200, width: 300, height: 300),
178+
onReactionTap: { _ in }
179+
)
180+
181+
// Then
182+
assertSnapshot(matching: view, as: .image)
183+
}
184+
185+
func test_reactionsAnimatableView_snapshot() {
186+
// Given
187+
let message = ChatMessage.mock(text: "Test message")
188+
let reactions: [MessageReactionType] = [.init(rawValue: "love"), .init(rawValue: "like")]
189+
190+
// When
191+
let view = ReactionsAnimatableView(
192+
message: message,
193+
reactions: reactions,
194+
onReactionTap: { _ in }
195+
)
196+
197+
// Then
198+
assertSnapshot(matching: view, as: .image)
199+
}
200+
201+
func test_chatMessage_reactionOffsetCurrentUser() {
202+
// Given
203+
let message = ChatMessage.mock(text: "Test message", isSentByCurrentUser: true)
204+
205+
// When
206+
let offset = message.reactionOffsetX(
207+
for: .init(origin: .zero, size: .init(width: 50, height: 50)),
208+
reactionsSize: 25
209+
)
210+
211+
// Then
212+
XCTAssert(offset == -12.5)
213+
}
214+
215+
func test_chatMessage_reactionOffsetOtherUser() {
216+
// Given
217+
let message = ChatMessage.mock(text: "Test message", isSentByCurrentUser: false)
218+
219+
// When
220+
let offset = message.reactionOffsetX(
221+
for: .init(origin: .zero, size: .init(width: 50, height: 50)),
222+
reactionsSize: 25
223+
)
224+
225+
// Then
226+
XCTAssert(offset == 12.5)
227+
}
150228
}
151229

152230
struct VerticallyCenteredView<Content: View>: View {
1.9 KB
Loading
3.12 KB
Loading
3.01 KB
Loading

StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,21 @@ class ViewFactory_Tests: StreamChatTestCase {
744744
// Then
745745
XCTAssert(view is TypingIndicatorBottomView)
746746
}
747+
748+
func test_viewFactory_makeReactionsContentView() {
749+
// Given
750+
let viewFactory = DefaultViewFactory.shared
751+
752+
// When
753+
let view = viewFactory.makeReactionsContentView(
754+
message: .mock(),
755+
contentRect: .zero,
756+
onReactionTap: { _ in }
757+
)
758+
759+
// Then
760+
XCTAssert(view is ReactionsOverlayContainer)
761+
}
747762
}
748763

749764
extension ChannelAction: Equatable {

0 commit comments

Comments
 (0)