Skip to content

Commit bdebe87

Browse files
authored
Add support for customising the MessageAvatarView placeholder (#878)
* Add support for customising the `MessageAvatarView` placeholder * Add test coverage * Update CHANGELOG.md
1 parent 70de99a commit bdebe87

13 files changed

+250
-30
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6-
### 🔄 Changed
6+
### ✅ Added
7+
- Add support for customising the `MessageAvatarView` placeholder [#878](https://github.com/GetStream/stream-chat-swiftui/pull/878)
78

89
# [4.81.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.81.0)
910
_July 03, 2025_

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageAvatarView.swift

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
import StreamChat
66
import SwiftUI
77

8-
public struct MessageAvatarView: View {
8+
public enum AvatarPlaceholderState {
9+
/// The placeholder when no image is available.
10+
case empty
11+
/// The placeholder shown while the image is loading.
12+
case loading
13+
/// The placeholder shown when there is an error loading the image.
14+
case error(Error)
15+
}
916

17+
public struct MessageAvatarView<Placeholder>: View where Placeholder: View {
1018
@Injected(\.utils) private var utils
1119
@Injected(\.colors) private var colors
1220
@Injected(\.images) private var images
@@ -18,15 +26,31 @@ public struct MessageAvatarView: View {
1826
var avatarURL: URL?
1927
var size: CGSize
2028
var showOnlineIndicator: Bool = false
29+
@ViewBuilder var placeholder: (AvatarPlaceholderState) -> Placeholder
2130

2231
public init(
2332
avatarURL: URL?,
2433
size: CGSize = CGSize.messageAvatarSize,
25-
showOnlineIndicator: Bool = false
34+
showOnlineIndicator: Bool = false,
35+
placeholder: @escaping (AvatarPlaceholderState) -> Placeholder
2636
) {
2737
self.avatarURL = avatarURL
2838
self.size = size
2939
self.showOnlineIndicator = showOnlineIndicator
40+
self.placeholder = placeholder
41+
}
42+
43+
public init(
44+
avatarURL: URL?,
45+
size: CGSize = CGSize.messageAvatarSize,
46+
showOnlineIndicator: Bool = false
47+
) where Placeholder == MessageAvatarDefaultPlaceholderView {
48+
self.avatarURL = avatarURL
49+
self.size = size
50+
self.showOnlineIndicator = showOnlineIndicator
51+
placeholder = { _ in
52+
MessageAvatarDefaultPlaceholderView(size: size)
53+
}
3054
}
3155

3256
public var body: some View {
@@ -36,35 +60,60 @@ public struct MessageAvatarView: View {
3660
preferredSize: size
3761
)
3862

39-
LazyImage(imageURL: adjustedURL)
40-
.onDisappear(.cancel)
41-
.priority(.normal)
42-
.clipShape(Circle())
43-
.frame(
44-
width: size.width,
45-
height: size.height
46-
)
47-
.overlay(
48-
showOnlineIndicator ?
49-
TopRightView {
50-
OnlineIndicatorView(indicatorSize: size.width * 0.3)
51-
}
52-
.offset(x: 3, y: -1)
53-
: nil
54-
)
55-
.accessibilityIdentifier("MessageAvatarView")
63+
LazyImage(imageURL: adjustedURL) { state in
64+
switch state {
65+
case let .loaded(image):
66+
NukeImage(image)
67+
case .loading:
68+
placeholder(.loading)
69+
case .placeholder:
70+
placeholder(.empty)
71+
case let .error(error):
72+
placeholder(.error(error))
73+
}
74+
}
75+
.onDisappear(.cancel)
76+
.priority(.normal)
77+
.clipShape(Circle())
78+
.frame(
79+
width: size.width,
80+
height: size.height
81+
)
82+
.overlay(
83+
showOnlineIndicator ?
84+
TopRightView {
85+
OnlineIndicatorView(indicatorSize: size.width * 0.3)
86+
}
87+
.offset(x: 3, y: -1)
88+
: nil
89+
)
90+
.accessibilityIdentifier("MessageAvatarView")
5691
} else {
57-
Image(uiImage: images.userAvatarPlaceholder2)
58-
.resizable()
59-
.frame(
60-
width: size.width,
61-
height: size.height
62-
)
63-
.accessibilityIdentifier("MessageAvatarViewPlaceholder")
92+
placeholder(.empty)
6493
}
6594
}
6695
}
6796

97+
public struct MessageAvatarDefaultPlaceholderView: View {
98+
@Injected(\.images) private var images
99+
100+
public let size: CGSize
101+
102+
public init(size: CGSize) {
103+
self.size = size
104+
}
105+
106+
public var body: some View {
107+
Image(uiImage: images.userAvatarPlaceholder2)
108+
.resizable()
109+
.frame(
110+
width: size.width,
111+
height: size.height
112+
)
113+
.accessibilityIdentifier("MessageAvatarViewPlaceholder")
114+
}
115+
}
116+
68117
extension CGSize {
69118
public static var messageAvatarSize = CGSize(width: 36, height: 36)
70119
}

Sources/StreamChatSwiftUI/Utils/LazyImageExtensions.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,31 @@
44

55
import SwiftUI
66

7+
enum LazyImageContentState {
8+
case loaded(UIImage)
9+
case placeholder
10+
case loading
11+
case error(Error)
12+
}
13+
714
extension LazyImage {
815

16+
init(
17+
imageURL: URL?,
18+
@ViewBuilder content: @escaping (LazyImageContentState) -> Content
19+
) {
20+
let placeholderContent: (LazyImageState) -> Content = { state in
21+
if let image = state.image {
22+
content(.loaded(image.imageContainer.image))
23+
} else if let error = state.error {
24+
content(.error(error))
25+
} else {
26+
content(.loading)
27+
}
28+
}
29+
self.init(imageURL: imageURL, content: placeholderContent)
30+
}
31+
932
init(imageURL: URL?) where Content == NukeImage {
1033
let imageCDN = InjectedValues[\.utils].imageCDN
1134
guard let imageURL = imageURL else {

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@
528528
AD51D9182DB9543A0068D0B0 /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */; };
529529
AD5C0A5F2D6FDD9700E1E500 /* BouncedMessageActionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */; };
530530
AD6B7E052D356E8800ADEF39 /* ReactionsUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */; };
531+
ADA77F052E1EC2B700A3641F /* MessageAvatarView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA77F042E1EC2B700A3641F /* MessageAvatarView_Tests.swift */; };
531532
ADE0F55E2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */; };
532533
ADE0F5602CB846EC0053B8B9 /* FloatingBannerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F55F2CB846EC0053B8B9 /* FloatingBannerViewModifier.swift */; };
533534
ADE0F5622CB8556F0053B8B9 /* ChatThreadListFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F5612CB8556F0053B8B9 /* ChatThreadListFooterView.swift */; };
@@ -1134,6 +1135,7 @@
11341135
AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = "<group>"; };
11351136
AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncedMessageActionsModifier.swift; sourceTree = "<group>"; };
11361137
AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsUsersViewModel.swift; sourceTree = "<group>"; };
1138+
ADA77F042E1EC2B700A3641F /* MessageAvatarView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageAvatarView_Tests.swift; sourceTree = "<group>"; };
11371139
ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListErrorBannerView.swift; sourceTree = "<group>"; };
11381140
ADE0F55F2CB846EC0053B8B9 /* FloatingBannerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingBannerViewModifier.swift; sourceTree = "<group>"; };
11391141
ADE0F5612CB8556F0053B8B9 /* ChatThreadListFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListFooterView.swift; sourceTree = "<group>"; };
@@ -2147,6 +2149,7 @@
21472149
846B15F22817E7440017F7A1 /* ChannelInfo */,
21482150
8423C340277CB5C70092DCF1 /* Suggestions */,
21492151
84779C742AEBBACD000A6A68 /* BottomReactionsView_Tests.swift */,
2152+
ADA77F042E1EC2B700A3641F /* MessageAvatarView_Tests.swift */,
21502153
844D1D672851DE58000CCCB9 /* ChannelControllerFactory_Tests.swift */,
21512154
842383DF2767394200888CFC /* ChatChannelDataSource_Tests.swift */,
21522155
846608E6278C95E700D3D7B3 /* ChatChannelExtensions_Tests.swift */,
@@ -3034,6 +3037,7 @@
30343037
84C94D1227578BF2007FE2B9 /* JSONEncoder+Extensions.swift in Sources */,
30353038
ADE442EE2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift in Sources */,
30363039
84E04797284A444E00BAFA17 /* WebSocketPingControllerMock.swift in Sources */,
3040+
ADA77F052E1EC2B700A3641F /* MessageAvatarView_Tests.swift in Sources */,
30373041
8423C34C277DDD250092DCF1 /* MuteCommandHandler_Tests.swift in Sources */,
30383042
84C94D1127578BF2007FE2B9 /* ChannelId.swift in Sources */,
30393043
84B2B5D628196FD100479CEE /* MediaAttachmentsView_Tests.swift in Sources */,

StreamChatSwiftUITests/Tests/ChatChannel/LazyImageExtensions_Tests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ final class LazyImageExtensions_Tests: StreamChatTestCase {
3434

3535
func test_imageRequest_emptyURL() {
3636
// Given
37-
let lazyImageView = LazyImage(imageURL: nil) { _ in
37+
let lazyImageView = LazyImage(request: nil) { _ in
3838
ProgressView()
3939
}
4040
.applyDefaultSize()
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import SnapshotTesting
6+
@testable import StreamChat
7+
@testable import StreamChatSwiftUI
8+
import StreamSwiftTestHelpers
9+
import SwiftUI
10+
import XCTest
11+
12+
class MessageAvatarView_Tests: StreamChatTestCase {
13+
14+
func test_messageAvatarView_defaultPlaceholder_empty() {
15+
// Given
16+
let view = MessageAvatarView(
17+
avatarURL: nil,
18+
size: CGSize(width: 36, height: 36),
19+
showOnlineIndicator: false
20+
)
21+
.frame(width: 50, height: 50)
22+
23+
// Then
24+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
25+
}
26+
27+
func test_messageAvatarView_defaultPlaceholder_withURL() {
28+
// Given
29+
let view = MessageAvatarView(
30+
avatarURL: URL(string: "https://example.com/avatar.jpg"),
31+
size: CGSize(width: 36, height: 36),
32+
showOnlineIndicator: false
33+
)
34+
.frame(width: 50, height: 50)
35+
36+
// Then
37+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
38+
}
39+
40+
func test_messageAvatarView_defaultPlaceholder_withOnlineIndicator() {
41+
// Given
42+
let view = MessageAvatarView(
43+
avatarURL: URL(string: "https://example.com/avatar.jpg"),
44+
size: CGSize(width: 36, height: 36),
45+
showOnlineIndicator: true
46+
)
47+
.frame(width: 50, height: 50)
48+
49+
// Then
50+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
51+
}
52+
53+
// MARK: - Custom Placeholder Tests
54+
55+
func test_messageAvatarView_customPlaceholder_empty() {
56+
// Given
57+
let view = MessageAvatarView(
58+
avatarURL: nil,
59+
size: CGSize(width: 36, height: 36),
60+
showOnlineIndicator: false
61+
) { state in
62+
CustomPlaceholderView(state: state, size: CGSize(width: 36, height: 36))
63+
}
64+
.frame(width: 50, height: 50)
65+
66+
// Then
67+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
68+
}
69+
70+
func test_messageAvatarView_customPlaceholder_loading() {
71+
// Given
72+
let view = MessageAvatarView(
73+
avatarURL: URL(string: "https://example.com/avatar.jpg"),
74+
size: CGSize(width: 36, height: 36),
75+
showOnlineIndicator: false
76+
) { state in
77+
CustomPlaceholderView(state: state, size: CGSize(width: 36, height: 36))
78+
}
79+
.frame(width: 50, height: 50)
80+
81+
// Then
82+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
83+
}
84+
85+
func test_messageAvatarView_customPlaceholder_error() {
86+
// Given
87+
struct FakeError: Error {}
88+
let view = MessageAvatarView(
89+
avatarURL: URL(string: "https://example.com/avatar.jpg"),
90+
size: CGSize(width: 36, height: 36),
91+
showOnlineIndicator: false
92+
) { _ in
93+
CustomPlaceholderView(
94+
state: .error(FakeError()),
95+
size: CGSize(width: 36, height: 36)
96+
)
97+
}
98+
.frame(width: 50, height: 50)
99+
100+
// Then
101+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
102+
}
103+
}
104+
105+
// MARK: - Custom Placeholder Views
106+
107+
struct CustomPlaceholderView: View {
108+
let state: AvatarPlaceholderState
109+
let size: CGSize
110+
111+
var body: some View {
112+
Circle()
113+
.fill(backgroundColor)
114+
.frame(width: size.width, height: size.height)
115+
.overlay(
116+
Image(systemName: iconName)
117+
.foregroundColor(.white)
118+
.font(.system(size: size.width * 0.4))
119+
)
120+
}
121+
122+
private var backgroundColor: Color {
123+
switch state {
124+
case .empty:
125+
return .blue
126+
case .loading:
127+
return .orange
128+
case .error:
129+
return .red
130+
}
131+
}
132+
133+
private var iconName: String {
134+
switch state {
135+
case .empty:
136+
return "person.fill"
137+
case .loading:
138+
return "hourglass"
139+
case .error:
140+
return "exclamationmark.triangle.fill"
141+
}
142+
}
143+
}
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)