Prefetch the next room list page before reaching the bottom

Growing the list only once the last row was already visible, on top of the half a
second visible range throttle, meant hitting and bouncing off the end of the list
while the next page loaded. Ask for the next page half a page early instead, so
the growth happens while the user is still scrolling towards the boundary.

The threshold is clamped so that it stays positive for accounts with fewer rooms
than half a page, where it would otherwise always match and leave the list unable
to shrink back to a single page.
This commit is contained in:
Stefan Ceriu
2026-08-18 16:57:42 +03:00
parent e7787f4c3f
commit 9dd9fd9176
2 changed files with 37 additions and 5 deletions
@@ -68,7 +68,10 @@ struct HomeScreenContent: View {
scrollViewAdapter.scrollView = scrollView
}
.onReceive(scrollViewAdapter.didScroll) { _ in
updateVisibleRange()
// Live, undelayed updates while scrolling: pagination needs to see the
// range early enough to grow the list before the user reaches the bottom,
// and the scroll geometry is valid mid-scroll.
sendVisibleRange()
}
.onReceive(scrollViewAdapter.isScrolling) { _ in
updateVisibleRange()
@@ -133,13 +136,21 @@ struct HomeScreenContent: View {
/// Often times the scroll view's content size isn't correct yet when this method is called e.g. when cancelling a search
/// Dispatch it with a delay to allow the UI to update and the computations to be correct
/// Once we move to iOS 17 we should remove all of this and use scroll anchors instead
/// Update: We're on iOS 26 now and the scroll achors still don't work properly.
private func updateVisibleRange() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { delayedUpdateVisibleRange() }
}
private func delayedUpdateVisibleRange() {
guard scrollViewAdapter.isScrolling.value == false else {
// Scrolling reports live through didScroll
return
}
sendVisibleRange()
}
private func sendVisibleRange() {
guard let scrollView = scrollViewAdapter.scrollView,
scrollViewAdapter.isScrolling.value == false, // Ignore while scrolling
context.searchQuery.isEmpty == true, // Ignore while filtering
!context.viewState.visibleRooms.isEmpty else {
return
@@ -19,6 +19,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let appSettings: AppSettings
private let roomListPageSize: UInt32
/// Remember how many rooms we had on the previous requests so we can deduplicate
private var roomCountOnLastPageAddRequest = -1
private let visibleItemRangePublisher = CurrentValueSubject<Range<Int>, Never>(0..<0)
@@ -182,6 +184,24 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
// MARK: - Private
private func setupVisibleRangeObservers() {
// Unthrottled to add another page half way through the last one
visibleItemRangePublisher
.sink { [weak self] range in
guard let self,
!range.isEmpty,
range.upperBound >= rooms.count - Int(roomListPageSize) / 2,
rooms.count != roomCountOnLastPageAddRequest else {
return
}
roomCountOnLastPageAddRequest = rooms.count
MXLog.info("\(self.name): Adding a page at \(rooms.count) rooms, visible range: \(range)")
listUpdatesSubscriptionResult?.controller().addOnePage()
}
.store(in: &cancellables)
visibleItemRangePublisher
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.removeDuplicates()
@@ -190,9 +210,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
MXLog.info("\(self.name): Updating visible range: \(range)")
if range.upperBound >= rooms.count {
listUpdatesSubscriptionResult?.controller().addOnePage()
} else if range.lowerBound == 0 {
if range.lowerBound == 0, range.upperBound < rooms.count {
roomCountOnLastPageAddRequest = -1
listUpdatesSubscriptionResult?.controller().resetToOnePage()
}
}
@@ -215,6 +234,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
range = range.lowerBound..<upperBound
}
MXLog.info("\(self.name): Subscribing to rooms in range: \(range)")
return range
.filter { $0 < self.rooms.count }
.map { self.rooms[$0].id }