Skip to content

Commit a2fc6a8

Browse files
committed
Use scroll offset for loading more content in channel and message list
1 parent 68ecf4f commit a2fc6a8

File tree

10 files changed

+147
-15
lines changed

10 files changed

+147
-15
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1414
- Fix mark unread action not removed when read events are disabled [#823](https://github.com/GetStream/stream-chat-swiftui/pull/823)
1515
- Fix user mentions not working when commands are disabled [#826](https://github.com/GetStream/stream-chat-swiftui/pull/826)
1616
- Fix edit message action shown when user does not have permissions [#835](https://github.com/GetStream/stream-chat-swiftui/pull/835)
17+
- Fix quickly scrolling in channel list stops loading next pages [#838](https://github.com/GetStream/stream-chat-swiftui/pull/838)
18+
### 🔄 Changed
19+
- `ChannelList` and `ChatChannelView` use content offset based load more instead of using item's `onAppear` [#838](https://github.com/GetStream/stream-chat-swiftui/pull/838)
1720

1821
# [4.78.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.78.0)
1922
_April 24, 2025_

DemoAppSwiftUI/iMessagePocView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ struct iMessagePocView: View {
5555
onItemAppear: viewModel.checkForChannels(index:),
5656
channelNaming: viewModel.name(forChannel:),
5757
channelDestination: factory.makeChannelDestination(),
58+
onLoadMoreChannels: viewModel.loadMoreChannels,
5859
trailingSwipeRightButtonTapped: viewModel.onDeleteTapped(channel:),
5960
trailingSwipeLeftButtonTapped: viewModel.onMoreTapped(channel:),
6061
leadingSwipeButtonTapped: viewModel.pinChannelTapped(_:)

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
5858
scrollPosition: $viewModel.scrollPosition,
5959
loadingNextMessages: viewModel.loadingNextMessages,
6060
firstUnreadMessageId: $viewModel.firstUnreadMessageId,
61+
onLoadPreviousMessages: viewModel.loadMorePreviousMessages,
62+
onLoadNextMessages: viewModel.loadMoreNextMessages,
6163
onMessageAppear: viewModel.handleMessageAppear(index:scrollDirection:),
6264
onScrollToBottom: viewModel.scrollToLastMessage,
6365
onLongPress: { displayInfo in
@@ -178,6 +180,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
178180
if utils.messageListConfig.becomesFirstResponderOnOpen {
179181
keyboardShown = true
180182
}
183+
viewModel.usesContentOffsetBasedLoadMore = true
181184
}
182185
.onDisappear {
183186
viewModel.onViewDissappear()

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
5151
private var onlineIndicatorShown = false
5252
private var lastReadMessageId: String?
5353
private let throttler = Throttler(interval: 3, broadcastLatestEvent: true)
54+
// Clean it up in v5 because it requires public API changes
55+
var usesContentOffsetBasedLoadMore = false
5456

5557
public var channelController: ChatChannelController
5658
public var messageController: ChatMessageController?
@@ -339,11 +341,14 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
339341
}
340342

341343
let message = messages[index]
342-
if scrollDirection == .up {
343-
checkForOlderMessages(index: index)
344-
} else {
345-
checkForNewerMessages(index: index)
344+
if !usesContentOffsetBasedLoadMore {
345+
if scrollDirection == .up {
346+
checkForOlderMessages(index: index)
347+
} else {
348+
checkForNewerMessages(index: index)
349+
}
346350
}
351+
347352
if let firstUnreadMessageId, firstUnreadMessageId.contains(message.id), hasSetInitialCanMarkRead {
348353
canMarkRead = true
349354
}
@@ -513,10 +518,27 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
513518
isActive = true
514519
}
515520

521+
/// Loads the previous page of messages.
522+
public func loadMorePreviousMessages() {
523+
usesContentOffsetBasedLoadMore = true
524+
_loadMorePreviousMessages()
525+
}
526+
527+
/// Loads the next page of messages.
528+
public func loadMoreNextMessages() {
529+
usesContentOffsetBasedLoadMore = true
530+
_loadMoreNextMessages()
531+
}
532+
516533
// MARK: - private
517534

518535
private func checkForOlderMessages(index: Int) {
536+
guard !usesContentOffsetBasedLoadMore else { return }
519537
guard index >= channelDataSource.messages.count - 25 else { return }
538+
_loadMorePreviousMessages()
539+
}
540+
541+
private func _loadMorePreviousMessages() {
520542
guard !loadingPreviousMessages else { return }
521543
guard !channelController.hasLoadedAllPreviousMessages else { return }
522544

@@ -533,16 +555,21 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
533555
}
534556
)
535557
}
536-
558+
537559
private func checkForNewerMessages(index: Int) {
560+
guard !usesContentOffsetBasedLoadMore else { return }
538561
guard index <= 5 else { return }
562+
_loadMoreNextMessages()
563+
}
564+
565+
private func _loadMoreNextMessages() {
539566
guard !loadingNextMessages else { return }
540567
guard !channelController.hasLoadedAllNextMessages else { return }
541568

542569
loadingNextMessages = true
543570

544571
if scrollPosition != messages.first?.messageId {
545-
scrollPosition = messages[index].messageId
572+
scrollPosition = messages.first?.messageId
546573
}
547574

548575
channelDataSource.loadNextMessages(limit: Self.newerMessagesLimit) { [weak self] _ in

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
2525
var isMessageThread: Bool
2626
var shouldShowTypingIndicator: Bool
2727

28+
var onLoadPreviousMessages: () -> Void
29+
var onLoadNextMessages: () -> Void
2830
var onMessageAppear: (Int, ScrollDirection) -> Void
2931
var onScrollToBottom: () -> Void
3032
var onLongPress: (MessageDisplayInfo) -> Void
@@ -77,6 +79,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
7779
scrollPosition: Binding<String?> = .constant(nil),
7880
loadingNextMessages: Bool = false,
7981
firstUnreadMessageId: Binding<MessageId?> = .constant(nil),
82+
onLoadPreviousMessages: @escaping () -> Void = { /* set for enabling content offset based loading */ },
83+
onLoadNextMessages: @escaping () -> Void = { /* set for enabling content offset based loading */ },
8084
onMessageAppear: @escaping (Int, ScrollDirection) -> Void,
8185
onScrollToBottom: @escaping () -> Void,
8286
onLongPress: @escaping (MessageDisplayInfo) -> Void,
@@ -90,6 +94,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
9094
self.listId = listId
9195
self.isMessageThread = isMessageThread
9296
self.onMessageAppear = onMessageAppear
97+
self.onLoadPreviousMessages = onLoadPreviousMessages
98+
self.onLoadNextMessages = onLoadNextMessages
9399
self.onScrollToBottom = onScrollToBottom
94100
self.onLongPress = onLongPress
95101
self.onJumpToMessage = onJumpToMessage
@@ -206,6 +212,12 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
206212
.delayedRendering()
207213
.modifier(factory.makeMessageListModifier())
208214
.modifier(ScrollTargetLayoutModifier(enabled: loadingNextMessages))
215+
.onScrollPaginationChanged(
216+
in: .named(scrollAreaId),
217+
flipped: true,
218+
onBottomThreshold: onLoadPreviousMessages,
219+
onTopThreshold: onLoadNextMessages
220+
)
209221
}
210222
.modifier(ScrollPositionModifier(scrollPosition: loadingNextMessages ? $scrollPosition : .constant(nil)))
211223
.background(

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
2222
private var onItemAppear: (Int) -> Void
2323
private var channelNaming: (ChatChannel) -> String
2424
private var channelDestination: (ChannelSelectionInfo) -> Factory.ChannelDestination
25+
private var onLoadMoreChannels: () -> Void
2526
private var trailingSwipeRightButtonTapped: (ChatChannel) -> Void
2627
private var trailingSwipeLeftButtonTapped: (ChatChannel) -> Void
2728
private var leadingSwipeButtonTapped: (ChatChannel) -> Void
@@ -38,6 +39,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
3839
onItemAppear: @escaping (Int) -> Void,
3940
channelNaming: ((ChatChannel) -> String)? = nil,
4041
channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,
42+
onLoadMoreChannels: @escaping () -> Void = { /* set for enabling content offset based loading */ },
4143
trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void = { _ in },
4244
trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void = { _ in },
4345
leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void = { _ in }
@@ -67,6 +69,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
6769
channel.shouldShowOnlineIndicator
6870
}
6971
}
72+
self.onLoadMoreChannels = onLoadMoreChannels
7073
self.trailingSwipeRightButtonTapped = trailingSwipeRightButtonTapped
7174
self.trailingSwipeLeftButtonTapped = trailingSwipeLeftButtonTapped
7275
self.leadingSwipeButtonTapped = leadingSwipeButtonTapped
@@ -99,6 +102,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
99102
onItemAppear: onItemAppear,
100103
channelNaming: channelNaming,
101104
channelDestination: channelDestination,
105+
onLoadMoreChannels: onLoadMoreChannels,
102106
trailingSwipeRightButtonTapped: trailingSwipeRightButtonTapped,
103107
trailingSwipeLeftButtonTapped: trailingSwipeLeftButtonTapped,
104108
leadingSwipeButtonTapped: leadingSwipeButtonTapped
@@ -118,6 +122,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
118122
private var imageLoader: (ChatChannel) -> UIImage
119123
private var onItemTap: (ChatChannel) -> Void
120124
private var onItemAppear: (Int) -> Void
125+
private var onLoadMoreChannels: () -> Void
121126
private var channelNaming: (ChatChannel) -> String
122127
private var channelDestination: (ChannelSelectionInfo) -> Factory.ChannelDestination
123128
private var trailingSwipeRightButtonTapped: (ChatChannel) -> Void
@@ -135,6 +140,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
135140
onItemAppear: @escaping (Int) -> Void,
136141
channelNaming: @escaping (ChatChannel) -> String,
137142
channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,
143+
onLoadMoreChannels: @escaping () -> Void = { /* set for enabling content offset based loading */ },
138144
trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void,
139145
trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void,
140146
leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void
@@ -147,6 +153,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
147153
self.channelDestination = channelDestination
148154
self.imageLoader = imageLoader
149155
self.onlineIndicatorShown = onlineIndicatorShown
156+
self.onLoadMoreChannels = onLoadMoreChannels
150157
self.trailingSwipeRightButtonTapped = trailingSwipeRightButtonTapped
151158
self.trailingSwipeLeftButtonTapped = trailingSwipeLeftButtonTapped
152159
self.leadingSwipeButtonTapped = leadingSwipeButtonTapped
@@ -189,6 +196,9 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
189196
factory.makeChannelListFooterView()
190197
}
191198
.modifier(factory.makeChannelListModifier())
199+
.onScrollPaginationChanged(
200+
onBottomThreshold: onLoadMoreChannels
201+
)
192202
}
193203
}
194204

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,14 @@ public struct ChatChannelListContentView<Factory: ViewFactory>: View {
244244
},
245245
channelNaming: viewModel.name(forChannel:),
246246
channelDestination: viewFactory.makeChannelDestination(),
247+
onLoadMoreChannels: viewModel.loadMoreChannels,
247248
trailingSwipeRightButtonTapped: viewModel.onDeleteTapped(channel:),
248249
trailingSwipeLeftButtonTapped: viewModel.onMoreTapped(channel:),
249250
leadingSwipeButtonTapped: { _ in /* No leading button by default. */ }
250251
)
251252
.onAppear {
252253
viewModel.preselectChannelIfNeeded()
254+
viewModel.usesContentOffsetBasedLoadMore = true
253255
}
254256
}
255257

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
4040

4141
/// Index of the selected channel.
4242
private var selectedChannelIndex: Int?
43+
44+
// Clean it up in v5 because it requires public API changes
45+
var usesContentOffsetBasedLoadMore = false
4346

4447
/// Published variables.
4548
@Published public var channels = LazyCachedMapCollection<ChatChannel>() {
@@ -163,23 +166,23 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
163166
channelNamer(channel, chatClient.currentUserId) ?? ""
164167
}
165168

166-
/// Checks if there are new channels to be loaded.
169+
/// Loads the next page of channels.
170+
public func loadMoreChannels() {
171+
usesContentOffsetBasedLoadMore = true
172+
_loadMoreChannels()
173+
}
174+
175+
/// Notifies that a channel for the specified index appeared.
167176
///
168177
/// - Parameter index: the currently displayed index.
169178
public func checkForChannels(index: Int) {
170179
handleChannelAppearance()
171180

172-
if index < (controller?.channels.count ?? 0) - 15 {
181+
if index < (controller?.channels.count ?? 0) - 15 || usesContentOffsetBasedLoadMore {
173182
return
174183
}
175184

176-
if !loadingNextChannels {
177-
loadingNextChannels = true
178-
controller?.loadNextChannels(limit: 30) { [weak self] _ in
179-
guard let self = self else { return }
180-
self.loadingNextChannels = false
181-
}
182-
}
185+
_loadMoreChannels()
183186
}
184187

185188
public func loadAdditionalSearchResults(index: Int) {
@@ -274,6 +277,16 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
274277

275278
// MARK: - private
276279

280+
private func _loadMoreChannels() {
281+
if !loadingNextChannels {
282+
loadingNextChannels = true
283+
controller?.loadNextChannels(limit: 30) { [weak self] _ in
284+
guard let self = self else { return }
285+
self.loadingNextChannels = false
286+
}
287+
}
288+
}
289+
277290
private func handleChannelListChanges(_ controller: ChatChannelListController) {
278291
if selectedChannel != nil || !searchText.isEmpty {
279292
queuedChannelsChanges = controller.channels
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
import SwiftUI
7+
8+
private struct ScrollViewPaginationViewModifier: ViewModifier {
9+
let coordinateSpace: CoordinateSpace
10+
let flipped: Bool
11+
let threshold: CGFloat
12+
let onBottomThreshold: () -> Void
13+
let onTopThreshold: (() -> Void)?
14+
15+
func body(content: Content) -> some View {
16+
content
17+
.background(
18+
GeometryReader { geometry in
19+
Color.clear
20+
.onChange(of: geometry.frame(in: coordinateSpace)) { _ in
21+
handleGeometryChanged(geometry)
22+
}
23+
}
24+
)
25+
}
26+
27+
func handleGeometryChanged(_ geometry: GeometryProxy) {
28+
let frame = geometry.frame(in: coordinateSpace)
29+
guard frame.size.height > 0 else { return }
30+
let offset = -frame.minY
31+
if offset + UIScreen.main.bounds.height + threshold > frame.height {
32+
flipped ? onTopThreshold?() : onBottomThreshold()
33+
} else if offset < threshold {
34+
flipped ? onBottomThreshold() : onTopThreshold?()
35+
}
36+
}
37+
}
38+
39+
extension View {
40+
func onScrollPaginationChanged(
41+
in coordinateSpace: CoordinateSpace = .global,
42+
flipped: Bool = false,
43+
threshold: CGFloat = 400,
44+
onBottomThreshold: @escaping () -> Void,
45+
onTopThreshold: (() -> Void)? = nil
46+
) -> some View {
47+
modifier(
48+
ScrollViewPaginationViewModifier(
49+
coordinateSpace: coordinateSpace,
50+
flipped: flipped,
51+
threshold: threshold,
52+
onBottomThreshold: onBottomThreshold,
53+
onTopThreshold: onTopThreshold
54+
)
55+
)
56+
}
57+
}

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
4FD3592A2C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD359292C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift */; };
2929
4FD964622D353D88001B6838 /* FilePickerView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD964612D353D82001B6838 /* FilePickerView_Tests.swift */; };
3030
4FEAB3182BFF71F70057E511 /* SwiftUI+UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */; };
31+
4FF435422DE59466003AF4B3 /* ScrollViewPaginationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */; };
3132
8205B4142AD41CC700265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4132AD41CC700265B84 /* StreamSwiftTestHelpers */; };
3233
8205B4182AD4267200265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4172AD4267200265B84 /* StreamSwiftTestHelpers */; };
3334
820A61A029D6D78E002257FB /* QuotedReply_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */; };
@@ -626,6 +627,7 @@
626627
4FD359292C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollViewModel_Tests.swift; sourceTree = "<group>"; };
627628
4FD964612D353D82001B6838 /* FilePickerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePickerView_Tests.swift; sourceTree = "<group>"; };
628629
4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+UIAlertController.swift"; sourceTree = "<group>"; };
630+
4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationViewModifier.swift; sourceTree = "<group>"; };
629631
820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReply_Tests.swift; sourceTree = "<group>"; };
630632
825AADF3283CCDB000237498 /* ThreadPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPage.swift; sourceTree = "<group>"; };
631633
829AB4D128578ACF002DC629 /* StreamTestCase+Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamTestCase+Tags.swift"; sourceTree = "<group>"; };
@@ -1915,6 +1917,7 @@
19151917
8465FD342746A95600AF091E /* Modifiers.swift */,
19161918
AD3AB64F2CB41B0D0014D4D7 /* NavigationContainerView.swift */,
19171919
8465FD332746A95600AF091E /* NukeImageLoader.swift */,
1920+
4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */,
19181921
84C0C9A228CF18F700CD0136 /* SnapshotCreator.swift */,
19191922
84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */,
19201923
8465FD3B2746A95600AF091E /* StringExtensions.swift */,
@@ -2689,6 +2692,7 @@
26892692
84289BEB2807239B00282ABE /* MediaAttachmentsViewModel.swift in Sources */,
26902693
8465FD902746A95700AF091E /* ComposerHelperViews.swift in Sources */,
26912694
82D64C142AD7E5B700C5C79E /* ImageDecoderRegistry.swift in Sources */,
2695+
4FF435422DE59466003AF4B3 /* ScrollViewPaginationViewModifier.swift in Sources */,
26922696
AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */,
26932697
82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */,
26942698
849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */,

0 commit comments

Comments
 (0)