Skip to content

Use scroll offset for loading more content in channel and message list #838

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🐞 Fixed
- Fix quickly scrolling in channel list stops loading next pages [#838](https://github.com/GetStream/stream-chat-swiftui/pull/838)
### 🔄 Changed
- `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)

# [4.79.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.79.0)
_May 29, 2025_
Expand Down
1 change: 1 addition & 0 deletions DemoAppSwiftUI/iMessagePocView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ struct iMessagePocView: View {
onItemAppear: viewModel.checkForChannels(index:),
channelNaming: viewModel.name(forChannel:),
channelDestination: factory.makeChannelDestination(),
onLoadMoreChannels: viewModel.loadMoreChannels,
trailingSwipeRightButtonTapped: viewModel.onDeleteTapped(channel:),
trailingSwipeLeftButtonTapped: viewModel.onMoreTapped(channel:),
leadingSwipeButtonTapped: viewModel.pinChannelTapped(_:)
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
scrollPosition: $viewModel.scrollPosition,
loadingNextMessages: viewModel.loadingNextMessages,
firstUnreadMessageId: $viewModel.firstUnreadMessageId,
onLoadPreviousMessages: viewModel.loadMorePreviousMessages,
onLoadNextMessages: viewModel.loadMoreNextMessages,
onMessageAppear: viewModel.handleMessageAppear(index:scrollDirection:),
onScrollToBottom: viewModel.scrollToLastMessage,
onLongPress: { displayInfo in
Expand Down Expand Up @@ -178,6 +180,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
if utils.messageListConfig.becomesFirstResponderOnOpen {
keyboardShown = true
}
viewModel.usesContentOffsetBasedLoadMore = true
}
.onDisappear {
viewModel.onViewDissappear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
private var onlineIndicatorShown = false
private var lastReadMessageId: String?
private let throttler = Throttler(interval: 3, broadcastLatestEvent: true)
// Clean it up in v5 because it requires public API changes
var usesContentOffsetBasedLoadMore = false

public var channelController: ChatChannelController
public var messageController: ChatMessageController?
Expand Down Expand Up @@ -339,11 +341,14 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

let message = messages[index]
if scrollDirection == .up {
checkForOlderMessages(index: index)
} else {
checkForNewerMessages(index: index)
if !usesContentOffsetBasedLoadMore {
if scrollDirection == .up {
checkForOlderMessages(index: index)
} else {
checkForNewerMessages(index: index)
}
}

if let firstUnreadMessageId, firstUnreadMessageId.contains(message.id), hasSetInitialCanMarkRead {
canMarkRead = true
}
Expand Down Expand Up @@ -513,10 +518,27 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
isActive = true
}

/// Loads the previous page of messages.
public func loadMorePreviousMessages() {
usesContentOffsetBasedLoadMore = true
_loadMorePreviousMessages()
}

/// Loads the next page of messages.
public func loadMoreNextMessages() {
usesContentOffsetBasedLoadMore = true
_loadMoreNextMessages()
}

// MARK: - private

private func checkForOlderMessages(index: Int) {
guard !usesContentOffsetBasedLoadMore else { return }
guard index >= channelDataSource.messages.count - 25 else { return }
_loadMorePreviousMessages()
}

private func _loadMorePreviousMessages() {
guard !loadingPreviousMessages else { return }
guard !channelController.hasLoadedAllPreviousMessages else { return }

Expand All @@ -533,16 +555,21 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
)
}

private func checkForNewerMessages(index: Int) {
guard !usesContentOffsetBasedLoadMore else { return }
guard index <= 5 else { return }
_loadMoreNextMessages()
}

private func _loadMoreNextMessages() {
guard !loadingNextMessages else { return }
guard !channelController.hasLoadedAllNextMessages else { return }

loadingNextMessages = true

if scrollPosition != messages.first?.messageId {
scrollPosition = messages[index].messageId
scrollPosition = messages.first?.messageId
}

channelDataSource.loadNextMessages(limit: Self.newerMessagesLimit) { [weak self] _ in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
var isMessageThread: Bool
var shouldShowTypingIndicator: Bool

var onLoadPreviousMessages: () -> Void
var onLoadNextMessages: () -> Void
var onMessageAppear: (Int, ScrollDirection) -> Void
var onScrollToBottom: () -> Void
var onLongPress: (MessageDisplayInfo) -> Void
Expand Down Expand Up @@ -77,6 +79,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
scrollPosition: Binding<String?> = .constant(nil),
loadingNextMessages: Bool = false,
firstUnreadMessageId: Binding<MessageId?> = .constant(nil),
onLoadPreviousMessages: @escaping () -> Void = { /* set for enabling content offset based loading */ },
onLoadNextMessages: @escaping () -> Void = { /* set for enabling content offset based loading */ },
onMessageAppear: @escaping (Int, ScrollDirection) -> Void,
onScrollToBottom: @escaping () -> Void,
onLongPress: @escaping (MessageDisplayInfo) -> Void,
Expand All @@ -90,6 +94,8 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
self.listId = listId
self.isMessageThread = isMessageThread
self.onMessageAppear = onMessageAppear
self.onLoadPreviousMessages = onLoadPreviousMessages
self.onLoadNextMessages = onLoadNextMessages
self.onScrollToBottom = onScrollToBottom
self.onLongPress = onLongPress
self.onJumpToMessage = onJumpToMessage
Expand Down Expand Up @@ -206,6 +212,12 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
.delayedRendering()
.modifier(factory.makeMessageListModifier())
.modifier(ScrollTargetLayoutModifier(enabled: loadingNextMessages))
.onScrollPaginationChanged(
in: .named(scrollAreaId),
flipped: true,
onBottomThreshold: onLoadPreviousMessages,
onTopThreshold: onLoadNextMessages
)
}
.modifier(ScrollPositionModifier(scrollPosition: loadingNextMessages ? $scrollPosition : .constant(nil)))
.background(
Expand Down
10 changes: 10 additions & 0 deletions Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
private var onItemAppear: (Int) -> Void
private var channelNaming: (ChatChannel) -> String
private var channelDestination: (ChannelSelectionInfo) -> Factory.ChannelDestination
private var onLoadMoreChannels: () -> Void
private var trailingSwipeRightButtonTapped: (ChatChannel) -> Void
private var trailingSwipeLeftButtonTapped: (ChatChannel) -> Void
private var leadingSwipeButtonTapped: (ChatChannel) -> Void
Expand All @@ -38,6 +39,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
onItemAppear: @escaping (Int) -> Void,
channelNaming: ((ChatChannel) -> String)? = nil,
channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,
onLoadMoreChannels: @escaping () -> Void = { /* set for enabling content offset based loading */ },
trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void = { _ in },
trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void = { _ in },
leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void = { _ in }
Expand Down Expand Up @@ -67,6 +69,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
channel.shouldShowOnlineIndicator
}
}
self.onLoadMoreChannels = onLoadMoreChannels
self.trailingSwipeRightButtonTapped = trailingSwipeRightButtonTapped
self.trailingSwipeLeftButtonTapped = trailingSwipeLeftButtonTapped
self.leadingSwipeButtonTapped = leadingSwipeButtonTapped
Expand Down Expand Up @@ -99,6 +102,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
onItemAppear: onItemAppear,
channelNaming: channelNaming,
channelDestination: channelDestination,
onLoadMoreChannels: onLoadMoreChannels,
trailingSwipeRightButtonTapped: trailingSwipeRightButtonTapped,
trailingSwipeLeftButtonTapped: trailingSwipeLeftButtonTapped,
leadingSwipeButtonTapped: leadingSwipeButtonTapped
Expand All @@ -118,6 +122,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
private var imageLoader: (ChatChannel) -> UIImage
private var onItemTap: (ChatChannel) -> Void
private var onItemAppear: (Int) -> Void
private var onLoadMoreChannels: () -> Void
private var channelNaming: (ChatChannel) -> String
private var channelDestination: (ChannelSelectionInfo) -> Factory.ChannelDestination
private var trailingSwipeRightButtonTapped: (ChatChannel) -> Void
Expand All @@ -135,6 +140,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
onItemAppear: @escaping (Int) -> Void,
channelNaming: @escaping (ChatChannel) -> String,
channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,
onLoadMoreChannels: @escaping () -> Void = { /* set for enabling content offset based loading */ },
trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void,
trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void,
leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void
Expand All @@ -147,6 +153,7 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
self.channelDestination = channelDestination
self.imageLoader = imageLoader
self.onlineIndicatorShown = onlineIndicatorShown
self.onLoadMoreChannels = onLoadMoreChannels
self.trailingSwipeRightButtonTapped = trailingSwipeRightButtonTapped
self.trailingSwipeLeftButtonTapped = trailingSwipeLeftButtonTapped
self.leadingSwipeButtonTapped = leadingSwipeButtonTapped
Expand Down Expand Up @@ -189,6 +196,9 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
factory.makeChannelListFooterView()
}
.modifier(factory.makeChannelListModifier())
.onScrollPaginationChanged(
onBottomThreshold: onLoadMoreChannels
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,14 @@ public struct ChatChannelListContentView<Factory: ViewFactory>: View {
},
channelNaming: viewModel.name(forChannel:),
channelDestination: viewFactory.makeChannelDestination(),
onLoadMoreChannels: viewModel.loadMoreChannels,
trailingSwipeRightButtonTapped: viewModel.onDeleteTapped(channel:),
trailingSwipeLeftButtonTapped: viewModel.onMoreTapped(channel:),
leadingSwipeButtonTapped: { _ in /* No leading button by default. */ }
)
.onAppear {
viewModel.preselectChannelIfNeeded()
viewModel.usesContentOffsetBasedLoadMore = true
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController

/// Index of the selected channel.
private var selectedChannelIndex: Int?

// Clean it up in v5 because it requires public API changes
var usesContentOffsetBasedLoadMore = false

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

/// Checks if there are new channels to be loaded.
/// Loads the next page of channels.
public func loadMoreChannels() {
usesContentOffsetBasedLoadMore = true
_loadMoreChannels()
}

/// Notifies that a channel for the specified index appeared.
///
/// - Parameter index: the currently displayed index.
public func checkForChannels(index: Int) {
handleChannelAppearance()

if index < (controller?.channels.count ?? 0) - 15 {
if index < (controller?.channels.count ?? 0) - 15 || usesContentOffsetBasedLoadMore {
return
}

if !loadingNextChannels {
loadingNextChannels = true
controller?.loadNextChannels(limit: 30) { [weak self] _ in
guard let self = self else { return }
self.loadingNextChannels = false
}
}
_loadMoreChannels()
}

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

// MARK: - private

private func _loadMoreChannels() {
if !loadingNextChannels {
loadingNextChannels = true
controller?.loadNextChannels(limit: 30) { [weak self] _ in
guard let self = self else { return }
self.loadingNextChannels = false
}
}
}

private func handleChannelListChanges(_ controller: ChatChannelListController) {
if selectedChannel != nil || !searchText.isEmpty {
queuedChannelsChanges = controller.channels
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation
import SwiftUI

private struct ScrollViewPaginationViewModifier: ViewModifier {
let coordinateSpace: CoordinateSpace
let flipped: Bool
let threshold: CGFloat
let onBottomThreshold: () -> Void
let onTopThreshold: (() -> Void)?

func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry in
Color.clear
.onChange(of: geometry.frame(in: coordinateSpace)) { _ in
handleGeometryChanged(geometry)
}
}
)
}

func handleGeometryChanged(_ geometry: GeometryProxy) {
let frame = geometry.frame(in: coordinateSpace)
guard frame.size.height > 0 else { return }
let offset = -frame.minY
if offset + UIScreen.main.bounds.height + threshold > frame.height {
flipped ? onTopThreshold?() : onBottomThreshold()
} else if offset < threshold {
flipped ? onBottomThreshold() : onTopThreshold?()
}
}
Comment on lines +27 to +36
Copy link
Contributor Author

@laevandus laevandus May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is way too difficult to get the height of the visible rect for precise calculations. Therefore, using inaccurate screen height here for simplifying it.
Geometry gives the height of the whole content view.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might slow things down though, wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah overall, it feels like on develop, things load faster and are bit smoother. 🤔

}

extension View {
func onScrollPaginationChanged(
in coordinateSpace: CoordinateSpace = .global,
flipped: Bool = false,
threshold: CGFloat = 400,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From UIKit SDK

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might need some fine-tuning

onBottomThreshold: @escaping () -> Void,
onTopThreshold: (() -> Void)? = nil
) -> some View {
modifier(
ScrollViewPaginationViewModifier(
coordinateSpace: coordinateSpace,
flipped: flipped,
threshold: threshold,
onBottomThreshold: onBottomThreshold,
onTopThreshold: onTopThreshold
)
)
}
}
4 changes: 4 additions & 0 deletions StreamChatSwiftUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
4FD3592A2C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD359292C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift */; };
4FD964622D353D88001B6838 /* FilePickerView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD964612D353D82001B6838 /* FilePickerView_Tests.swift */; };
4FEAB3182BFF71F70057E511 /* SwiftUI+UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */; };
4FF435422DE59466003AF4B3 /* ScrollViewPaginationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */; };
8205B4142AD41CC700265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4132AD41CC700265B84 /* StreamSwiftTestHelpers */; };
8205B4182AD4267200265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4172AD4267200265B84 /* StreamSwiftTestHelpers */; };
820A61A029D6D78E002257FB /* QuotedReply_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */; };
Expand Down Expand Up @@ -632,6 +633,7 @@
4FD359292C05EA8F00B1D63B /* CreatePollViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollViewModel_Tests.swift; sourceTree = "<group>"; };
4FD964612D353D82001B6838 /* FilePickerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePickerView_Tests.swift; sourceTree = "<group>"; };
4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+UIAlertController.swift"; sourceTree = "<group>"; };
4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationViewModifier.swift; sourceTree = "<group>"; };
820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReply_Tests.swift; sourceTree = "<group>"; };
825AADF3283CCDB000237498 /* ThreadPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPage.swift; sourceTree = "<group>"; };
829AB4D128578ACF002DC629 /* StreamTestCase+Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamTestCase+Tags.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1921,6 +1923,7 @@
8465FD342746A95600AF091E /* Modifiers.swift */,
AD3AB64F2CB41B0D0014D4D7 /* NavigationContainerView.swift */,
8465FD332746A95600AF091E /* NukeImageLoader.swift */,
4FF435412DE5945E003AF4B3 /* ScrollViewPaginationViewModifier.swift */,
84C0C9A228CF18F700CD0136 /* SnapshotCreator.swift */,
84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */,
8465FD3B2746A95600AF091E /* StringExtensions.swift */,
Expand Down Expand Up @@ -2698,6 +2701,7 @@
84289BEB2807239B00282ABE /* MediaAttachmentsViewModel.swift in Sources */,
8465FD902746A95700AF091E /* ComposerHelperViews.swift in Sources */,
82D64C142AD7E5B700C5C79E /* ImageDecoderRegistry.swift in Sources */,
4FF435422DE59466003AF4B3 /* ScrollViewPaginationViewModifier.swift in Sources */,
AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */,
82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */,
849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */,
Expand Down
Loading