Skip to content

Commit e4a405e

Browse files
authored
Optimise channel list view updates (#561)
* Remove explicit view ids in the channel list * Update swipedChannelId less * Optimise avatar refreshes by not using observable object on the list level
1 parent 3d5e6a6 commit e4a405e

File tree

13 files changed

+150
-69
lines changed

13 files changed

+150
-69
lines changed

CHANGELOG.md

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

44
# Upcoming
55

6+
### ⚡ Performance
7+
- Optimise channel list view updates [#561](https://github.com/GetStream/stream-chat-swiftui/pull/561)
8+
69
### 🐞 Fixed
710
- Media and files attachments not showing in channel info view [#554](https://github.com/GetStream/stream-chat-swiftui/pull/554)
811
- Bottom reactions configuration not always updating reactions [#557](https://github.com/GetStream/stream-chat-swiftui/pull/557)
912

13+
### 🔄 Changed
14+
- Channel list views do not use explicit id anymore [#561](https://github.com/GetStream/stream-chat-swiftui/pull/561)
15+
- Deprecate `ChannelAvatarView` initializer `init(avatar:showOnlineIndicator:size:)` in favor of `init(channel:showOnlineIndicator:size:)` [#561](https://github.com/GetStream/stream-chat-swiftui/pull/561)
16+
1017
# [4.60.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.60.0)
1118
_July 19, 2024_
1219

DemoAppSwiftUI/PinChannelHelpers.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct DemoAppChatChannelListItem: View {
4040
} label: {
4141
HStack {
4242
ChannelAvatarView(
43-
avatar: avatar,
43+
channel: channel,
4444
showOnlineIndicator: onlineIndicatorShown
4545
)
4646

@@ -83,7 +83,6 @@ struct DemoAppChatChannelListItem: View {
8383
.foregroundColor(.black)
8484
.disabled(disabled)
8585
.background(channel.isPinned ? Color(colors.pinnedBackground) : .clear)
86-
.id("\(channel.id)-\(channel.isPinned ? "pinned" : "not-pinned")-base")
8786
}
8887

8988
private var subtitleView: some View {
@@ -173,7 +172,6 @@ struct DemoAppChatChannelNavigatableListItem<ChannelDestination: View>: View {
173172
EmptyView()
174173
}
175174
}
176-
.id("\(channel.id)-\(channel.isPinned ? "pinned" : "not-pinned")-base")
177175
}
178176

179177
private var injectedChannelInfo: InjectedChannelInfo? {

DemoAppSwiftUI/WhatsAppChannelHeader.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ struct WhatsAppChannelHeader: ToolbarContent {
5353
ToolbarItem(placement: .principal) {
5454
HStack {
5555
ChannelAvatarView(
56-
avatar: channelHeaderLoader.image(for: channel),
56+
channel: channel,
5757
showOnlineIndicator: false,
5858
size: CGSize(width: 36, height: 36)
5959
)

DemoAppSwiftUI/iMessagePocView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct iMessagePocView: View {
3333
HStack {
3434
ForEach(viewModel.pinnedChannels) { channel in
3535
ChannelAvatarView(
36-
avatar: channelHeaderLoader.image(for: channel),
36+
channel: channel,
3737
showOnlineIndicator: false
3838
)
3939
.padding()

Sources/StreamChatSwiftUI/ChatChannelList/ChannelHeaderLoader.swift

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright © 2024 Stream.io Inc. All rights reserved.
33
//
44

5+
import Combine
56
import Foundation
67
import StreamChat
78
import UIKit
@@ -15,10 +16,10 @@ open class ChannelHeaderLoader: ObservableObject {
1516
private let maxNumberOfImagesInCombinedAvatar = 4
1617

1718
/// Prevents image requests to be executed if they failed previously.
18-
private var failedImageLoads = Set<String>()
19+
private var failedImageLoads = Set<ChannelId>()
1920

2021
/// Batches loaded images for update, to improve performance.
21-
private var scheduledUpdate = false
22+
private var scheduledUpdates = Set<ChannelId>()
2223

2324
/// Context provided utils.
2425
internal lazy var imageLoader = utils.imageLoader
@@ -31,18 +32,9 @@ open class ChannelHeaderLoader: ObservableObject {
3132
internal lazy var placeholder2 = images.userAvatarPlaceholder2
3233
internal lazy var placeholder3 = images.userAvatarPlaceholder3
3334
internal lazy var placeholder4 = images.userAvatarPlaceholder4
34-
35-
var loadedImages = [String: UIImage]() {
36-
willSet {
37-
if !scheduledUpdate {
38-
scheduledUpdate = true
39-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
40-
self?.objectWillChange.send()
41-
self?.scheduledUpdate = false
42-
}
43-
}
44-
}
45-
}
35+
36+
private var loadedImages = [ChannelId: UIImage]()
37+
private let didLoadImage = PassthroughSubject<ChannelId, Never>()
4638

4739
public init() {
4840
// Public init.
@@ -53,19 +45,19 @@ open class ChannelHeaderLoader: ObservableObject {
5345
/// - Parameter channel: the provided channel.
5446
/// - Returns: the available image.
5547
public func image(for channel: ChatChannel) -> UIImage {
56-
if let image = loadedImages[channel.cid.rawValue] {
48+
if let image = loadedImages[channel.cid] {
5749
return image
5850
}
5951

6052
if let url = channel.imageURL {
61-
loadChannelThumbnail(for: channel, from: url)
53+
loadChannelThumbnail(for: channel.cid, from: url)
6254
return placeholder4
6355
}
6456

6557
if channel.isDirectMessageChannel {
6658
let lastActiveMembers = self.lastActiveMembers(for: channel)
6759
if let otherMember = lastActiveMembers.first, let url = otherMember.imageURL {
68-
loadChannelThumbnail(for: channel, from: url)
60+
loadChannelThumbnail(for: channel.cid, from: url)
6961
return placeholder3
7062
} else {
7163
return placeholder4
@@ -84,16 +76,38 @@ open class ChannelHeaderLoader: ObservableObject {
8476
if urls.isEmpty {
8577
return placeholder3
8678
} else {
87-
loadMergedAvatar(from: channel, urls: Array(urls))
79+
loadMergedAvatar(from: channel.cid, urls: Array(urls))
8880
return placeholder4
8981
}
9082
}
9183
}
9284

85+
func channelAvatarChanged(_ cid: ChannelId?) -> AnyPublisher<Void, Never> {
86+
didLoadImage
87+
.filter { $0 == cid }
88+
.map { _ in () }
89+
.eraseToAnyPublisher()
90+
}
91+
9392
// MARK: - private
9493

95-
private func loadMergedAvatar(from channel: ChatChannel, urls: [URL]) {
96-
if failedImageLoads.contains(channel.cid.rawValue) {
94+
private func didFinishedLoading(for cid: ChannelId, image: UIImage) {
95+
loadedImages[cid] = image
96+
97+
if scheduledUpdates.isEmpty {
98+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
99+
guard let self else { return }
100+
let updates = self.scheduledUpdates
101+
self.scheduledUpdates.removeAll()
102+
updates.forEach { self.didLoadImage.send($0) }
103+
}
104+
}
105+
106+
scheduledUpdates.insert(cid)
107+
}
108+
109+
private func loadMergedAvatar(from cid: ChannelId, urls: [URL]) {
110+
if failedImageLoads.contains(cid) {
97111
return
98112
}
99113

@@ -109,20 +123,20 @@ open class ChannelHeaderLoader: ObservableObject {
109123
let image = self.channelAvatarsMerger.createMergedAvatar(from: images)
110124
DispatchQueue.main.async {
111125
if let image = image {
112-
self.loadedImages[channel.cid.rawValue] = image
126+
self.didFinishedLoading(for: cid, image: image)
113127
} else {
114-
self.failedImageLoads.insert(channel.cid.rawValue)
128+
self.failedImageLoads.insert(cid)
115129
}
116130
}
117131
}
118132
}
119133
}
120134

121135
private func loadChannelThumbnail(
122-
for channel: ChatChannel,
136+
for cid: ChannelId,
123137
from url: URL
124138
) {
125-
if failedImageLoads.contains(channel.cid.rawValue) {
139+
if failedImageLoads.contains(cid) {
126140
return
127141
}
128142

@@ -136,10 +150,10 @@ open class ChannelHeaderLoader: ObservableObject {
136150
switch result {
137151
case let .success(image):
138152
DispatchQueue.main.async {
139-
self.loadedImages[channel.cid.rawValue] = image
153+
self.didFinishedLoading(for: cid, image: image)
140154
}
141155
case let .failure(error):
142-
self.failedImageLoads.insert(channel.cid.rawValue)
156+
self.failedImageLoads.insert(cid)
143157
log.error("error loading image: \(error.localizedDescription)")
144158
}
145159
}

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -189,29 +189,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
189189

190190
/// Determines the uniqueness of the channel list item.
191191
extension ChatChannel: Identifiable {
192-
private var mutedString: String {
193-
isMuted ? "muted" : "unmuted"
194-
}
195-
196192
public var id: String {
197-
"\(cid.id)-\(lastMessageAt ?? createdAt)-\(lastActiveMembersCount)-\(mutedString)-\(typingUsersString)-\(readUsersId)-\(unreadCount.messages)"
198-
}
199-
200-
private var readUsersId: String {
201-
"\(readUsers(currentUserId: nil, message: latestMessages.first).count)"
202-
}
203-
204-
private var lastActiveMembersCount: Int {
205-
lastActiveMembers.filter { member in
206-
member.isOnline
207-
}
208-
.count
209-
}
210-
211-
private var typingUsersString: String {
212-
currentlyTypingUsers.map { user in
213-
user.id
214-
}
215-
.joined(separator: "-")
193+
cid.rawValue
216194
}
217195
}

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public struct ChatChannelListItem: View {
4646
} label: {
4747
HStack {
4848
ChannelAvatarView(
49-
avatar: avatar,
49+
channel: channel,
5050
showOnlineIndicator: onlineIndicatorShown
5151
)
5252

@@ -129,24 +129,45 @@ public struct ChatChannelListItem: View {
129129

130130
/// View for the avatar used in channels (includes online indicator overlay).
131131
public struct ChannelAvatarView: View {
132-
133-
var avatar: UIImage
134-
var showOnlineIndicator: Bool
135-
var size: CGSize = .defaultAvatarSize
136-
132+
@Injected(\.utils) private var utils
133+
let avatar: UIImage?
134+
let showOnlineIndicator: Bool
135+
let size: CGSize
136+
137+
@State private var channelAvatar = UIImage()
138+
let channel: ChatChannel?
139+
140+
@available(
141+
*,
142+
deprecated,
143+
renamed: "init(channel:showOnlineIndicator:size:)",
144+
message: "Use automatically refreshing avatar initializer."
145+
)
137146
public init(
138147
avatar: UIImage,
139148
showOnlineIndicator: Bool,
140149
size: CGSize = .defaultAvatarSize
141150
) {
142151
self.avatar = avatar
152+
channel = nil
153+
self.showOnlineIndicator = showOnlineIndicator
154+
self.size = size
155+
}
156+
157+
public init(
158+
channel: ChatChannel,
159+
showOnlineIndicator: Bool,
160+
size: CGSize = .defaultAvatarSize
161+
) {
162+
avatar = nil
163+
self.channel = channel
143164
self.showOnlineIndicator = showOnlineIndicator
144165
self.size = size
145166
}
146167

147168
public var body: some View {
148169
LazyView(
149-
AvatarView(avatar: avatar, size: size)
170+
AvatarView(avatar: image, size: size)
150171
.overlay(
151172
showOnlineIndicator ?
152173
TopRightView {
@@ -155,9 +176,26 @@ public struct ChannelAvatarView: View {
155176
.offset(x: 3, y: -1)
156177
: nil
157178
)
179+
.onLoad {
180+
reloadAvatar()
181+
}
182+
.onReceive(channelHeaderLoader.channelAvatarChanged(channel?.cid)) { _ in
183+
reloadAvatar()
184+
}
158185
)
159186
.accessibilityIdentifier("ChannelAvatarView")
160187
}
188+
189+
private var channelHeaderLoader: ChannelHeaderLoader { utils.channelHeaderLoader }
190+
191+
private var image: UIImage {
192+
avatar ?? channelAvatar
193+
}
194+
195+
private func reloadAvatar() {
196+
guard let channel else { return }
197+
channelAvatar = utils.channelHeaderLoader.image(for: channel)
198+
}
161199
}
162200

163201
/// View used for the online indicator.

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ public struct ChatChannelListContentView<Factory: ViewFactory>: View {
182182

183183
private var viewFactory: Factory
184184
@ObservedObject private var viewModel: ChatChannelListViewModel
185-
@ObservedObject private var channelHeaderLoader = InjectedValues[\.utils].channelHeaderLoader
185+
private var channelHeaderLoader: ChannelHeaderLoader { InjectedValues[\.utils].channelHeaderLoader }
186186
private var onItemTap: (ChatChannel) -> Void
187187

188188
public init(

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ public struct ChatChannelNavigatableListItem<ChannelDestination: View>: View {
5858
EmptyView()
5959
}
6060
}
61-
.id("\(channel.id)-navigatable")
6261
}
6362

6463
private var injectedChannelInfo: InjectedChannelInfo? {

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelSwipeableListItem.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ public struct ChatChannelSwipeableListItem<Factory: ViewFactory, ChannelListItem
107107
setOffsetX(value: 0)
108108
}
109109
})
110-
.id("\(channel.id)-swipeable")
111110
.accessibilityIdentifier("ChatChannelSwipeableListItem")
112111
}
113112

@@ -175,15 +174,23 @@ public struct ChatChannelSwipeableListItem<Factory: ViewFactory, ChannelListItem
175174
self.offsetX = value
176175
}
177176
if offsetX == 0 {
178-
openSideLock = nil
179-
swipedChannelId = nil
177+
if openSideLock != nil {
178+
openSideLock = nil
179+
}
180+
if swipedChannelId != nil {
181+
swipedChannelId = nil
182+
}
180183
}
181184
}
182185

183186
private func dragEnded() {
184187
if offsetX == 0 {
185-
swipedChannelId = nil
186-
openSideLock = nil
188+
if swipedChannelId != nil {
189+
swipedChannelId = nil
190+
}
191+
if openSideLock != nil {
192+
openSideLock = nil
193+
}
187194
} else if offsetX > 0 && showLeadingSwipeActions {
188195
if offsetX.magnitude < openTriggerValue ||
189196
offsetX < menuWidth * 0.8 {

0 commit comments

Comments
 (0)