Skip to content

Commit ac9bc95

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

File tree

12 files changed

+110
-23
lines changed

12 files changed

+110
-23
lines changed

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: 2 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

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,6 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
339339
}
340340

341341
let message = messages[index]
342-
if scrollDirection == .up {
343-
checkForOlderMessages(index: index)
344-
} else {
345-
checkForNewerMessages(index: index)
346-
}
347342
if let firstUnreadMessageId, firstUnreadMessageId.contains(message.id), hasSetInitialCanMarkRead {
348343
canMarkRead = true
349344
}
@@ -513,10 +508,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
513508
isActive = true
514509
}
515510

516-
// MARK: - private
517-
518-
private func checkForOlderMessages(index: Int) {
519-
guard index >= channelDataSource.messages.count - 25 else { return }
511+
/// Loads the previous page of messages.
512+
public func loadMorePreviousMessages() {
520513
guard !loadingPreviousMessages else { return }
521514
guard !channelController.hasLoadedAllPreviousMessages else { return }
522515

@@ -533,16 +526,16 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
533526
}
534527
)
535528
}
536-
537-
private func checkForNewerMessages(index: Int) {
538-
guard index <= 5 else { return }
529+
530+
/// Loads the next page of messages.
531+
public func loadMoreNextMessages() {
539532
guard !loadingNextMessages else { return }
540533
guard !channelController.hasLoadedAllNextMessages else { return }
541534

542535
loadingNextMessages = true
543536

544537
if scrollPosition != messages.first?.messageId {
545-
scrollPosition = messages[index].messageId
538+
scrollPosition = messages.first?.messageId
546539
}
547540

548541
channelDataSource.loadNextMessages(limit: Self.newerMessagesLimit) { [weak self] _ in
@@ -553,6 +546,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
553546
}
554547
}
555548

549+
// MARK: - private
550+
556551
private func save(lastDate: Date) {
557552
if disableDateIndicator {
558553
enableDateIndicator()

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,
83+
onLoadNextMessages: @escaping () -> Void,
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,
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,
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ 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. */ }

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,16 +163,8 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
163163
channelNamer(channel, chatClient.currentUserId) ?? ""
164164
}
165165

166-
/// Checks if there are new channels to be loaded.
167-
///
168-
/// - Parameter index: the currently displayed index.
169-
public func checkForChannels(index: Int) {
170-
handleChannelAppearance()
171-
172-
if index < (controller?.channels.count ?? 0) - 15 {
173-
return
174-
}
175-
166+
/// Loads the next page of channels.
167+
public func loadMoreChannels() {
176168
if !loadingNextChannels {
177169
loadingNextChannels = true
178170
controller?.loadNextChannels(limit: 30) { [weak self] _ in
@@ -181,6 +173,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
181173
}
182174
}
183175
}
176+
177+
/// Notifies that a channel for the specified index appeared.
178+
///
179+
/// - Parameter index: the currently displayed index.
180+
public func checkForChannels(index: Int) {
181+
handleChannelAppearance()
182+
}
184183

185184
public func loadAdditionalSearchResults(index: Int) {
186185
switch searchType {
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 */,

StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewAvatars_Tests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ class MessageListViewAvatars_Tests: StreamChatTestCase {
9797
listId: "listId",
9898
isMessageThread: false,
9999
shouldShowTypingIndicator: false,
100+
onLoadPreviousMessages: {},
101+
onLoadNextMessages: {},
100102
onMessageAppear: { _, _ in },
101103
onScrollToBottom: {},
102104
onLongPress: { _ in }

StreamChatSwiftUITests/Tests/ChatChannel/MessageListViewNewMessages_Tests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ final class MessageListViewNewMessages_Tests: StreamChatTestCase {
113113
listId: "listId",
114114
isMessageThread: false,
115115
shouldShowTypingIndicator: false,
116+
onLoadPreviousMessages: {},
117+
onLoadNextMessages: {},
116118
onMessageAppear: { _, _ in },
117119
onScrollToBottom: {},
118120
onLongPress: { _ in }

StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ class MessageListView_Tests: StreamChatTestCase {
131131
listId: "listId",
132132
isMessageThread: false,
133133
shouldShowTypingIndicator: !currentlyTypingUsers.isEmpty,
134+
onLoadPreviousMessages: {},
135+
onLoadNextMessages: {},
134136
onMessageAppear: { _, _ in },
135137
onScrollToBottom: {},
136138
onLongPress: { _ in }

0 commit comments

Comments
 (0)