Rewrote timeline back pagination on top of direct access to the underlying UITableView.

This commit is contained in:
Stefan Ceriu
2022-03-15 13:06:50 +02:00
parent 464cba93a0
commit a2748497c7
7 changed files with 230 additions and 157 deletions

View File

@@ -29,4 +29,5 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState {
var roomTitle: String = ""
var timelineItems: [RoomTimelineViewProvider] = []
var isBackPaginating = false
}

View File

@@ -24,7 +24,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState,
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private struct Constants {
static let backPaginationPageSize: UInt = 20
static let backPaginationPageSize: UInt = 30
}
private let roomProxy: RoomProxyProtocol
@@ -58,7 +58,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .loadPreviousPage:
timelineController.paginateBackwards(Constants.backPaginationPageSize)
state.isBackPaginating = true
timelineController.paginateBackwards(Constants.backPaginationPageSize) { [weak self] _ in
self?.state.isBackPaginating = false
}
case .itemAppeared:
break
case .itemDisappeared:

View File

@@ -20,47 +20,32 @@ import Combine
struct RoomScreen: View {
@State private var scrollViewObserver: ScrollViewObserver = ScrollViewObserver()
@State private var tableViewObserver: TableViewObserver = TableViewObserver()
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var didRequestBackPagination = false
@State private var hasPendingMessages = false
@State private var wasBottomVisible = false
@State private var previousTopMostMessageIdentifier: String?
private let timelineBottomAnchor = "TimelineBottomAnchor"
@State private var hasPendingChanges = false
@State private var text: String = ""
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
ScrollViewReader { scrollViewProxy in
// The observer behaves differently when not in an reader
ScrollViewReader { _ in
List {
if didRequestBackPagination == false {
HStack {
Spacer()
ProgressView()
Spacer()
}
.onAppear {
guard didRequestBackPagination == false else {
return
}
didRequestBackPagination = true
context.send(viewAction: .loadPreviousPage)
}
} else {
HStack {
Spacer()
ProgressView()
Spacer()
}
HStack {
Spacer()
ProgressView()
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.default, value: context.viewState.isBackPaginating)
Spacer()
}
ForEach(timelineItems) { timelineItem in
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.timelineItems : timelineItems) { timelineItem in
timelineItem
.listRowSeparator(.hidden)
.task {
}
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
@@ -68,121 +53,190 @@ struct RoomScreen: View {
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
}
Color.clear
.listRowSeparator(.hidden)
.id(timelineBottomAnchor)
}
.listStyle(.plain)
.navigationTitle(context.viewState.roomTitle)
.environment(\.defaultMinListRowHeight, 0.0)
.navigationBarTitleDisplayMode(.inline)
// Fetch the underlying UIScrollView and start observing it
.introspectTableView { scrollView in
if scrollView == scrollViewObserver.scrollView {
.introspectTableView { tableView in
if tableView == tableViewObserver.tableView {
return
}
scrollViewObserver = ScrollViewObserver(scrollView: scrollView)
}
// Scroll to the bottom when the timeline first appears
.onAppear {
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
}
// When the view state changes check whether the user is interacting with the scroll view.
// Updating in that case causes undesired scrolling. Delay until the scroll view stops scrolling.
// Also store previous top most message identifier to have something to scroll to after the update.
.onChange(of: context.viewState.timelineItems) { newValue in
previousTopMostMessageIdentifier = timelineItems.first?.id
wasBottomVisible = scrollViewObserver.isBottomVisible
tableViewObserver = TableViewObserver(tableView: tableView)
if scrollViewObserver.isTracking == true {
hasPendingMessages = true
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onReceive(tableViewObserver.scrollViewDidReachTop, perform: {
if context.viewState.isBackPaginating {
return
}
timelineItems = newValue
}
// Check if we have pending messages to apply and apply them when the scroll finishes scrolling
.onReceive(scrollViewObserver.didEndScrolling, perform: {
if hasPendingMessages {
timelineItems = context.viewState.timelineItems
hasPendingMessages = false
}
attemptBackPagination()
})
// Process timeline updates
.onChange(of: timelineItems, perform: { _ in
if didRequestBackPagination && wasBottomVisible {
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
} else if didRequestBackPagination == false {
if wasBottomVisible {
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
}
} else {
// Manual scrolling breaks inertia. Don't do it if the scroll view is decelerating
if scrollViewObserver.isDecelerating == false {
scrollViewProxy.scrollTo(previousTopMostMessageIdentifier, anchor: .top)
}
.onChange(of: context.viewState.timelineItems) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
hasPendingChanges = true
return
}
didRequestBackPagination = false
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.timelineItems
}
.onReceive(tableViewObserver.scrollViewDidRest, perform: {
if hasPendingChanges == false {
return
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.timelineItems
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
tableViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
})
}
}
private func attemptBackPagination() {
if context.viewState.isBackPaginating {
return
}
if tableViewObserver.isTopVisible == false {
return
}
context.send(viewAction: .loadPreviousPage)
}
private var isPreview: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
return false
#endif
}
}
/// Simple wrapper around an UIScrollView that publishes when it finishes scrolling
class ScrollViewObserver: NSObject, UIScrollViewDelegate {
private(set) var scrollView: UIScrollView?
private class TableViewObserver: NSObject, UITableViewDelegate {
let didEndScrolling = PassthroughSubject<Void, Never>()
private enum ContentOffsetDetails {
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
case bottomOffset
}
private let topTriggerHeight = 50.0
private var isAtTop: Bool = false
private var offsetDetails: ContentOffsetDetails?
private(set) var tableView: UITableView?
let scrollViewDidRest = PassthroughSubject<Void, Never>()
let scrollViewDidReachTop = PassthroughSubject<Void, Never>()
override init() {
}
init(scrollView: UIScrollView) {
self.scrollView = scrollView
init(tableView: UITableView) {
self.tableView = tableView
super.init()
scrollView.delegate = self
tableView.delegate = self
}
func saveCurrentOffset() {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
if isBottomVisible {
offsetDetails = .bottomOffset
} else if isTopVisible {
if let topIndexPath = tableView.indexPathsForVisibleRows?.first {
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
previousItemCount: tableView.numberOfRows(inSection: 0))
}
}
}
func restoreSavedOffset() {
defer {
offsetDetails = nil
}
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
switch offsetDetails {
case .bottomOffset:
tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
case .topOffset(let indexPath, let previousItemCount):
let row = indexPath.row + max(0, (currentItemCount - previousItemCount))
if row < currentItemCount {
tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false)
}
case .none:
break
}
}
var isTracking: Bool {
self.scrollView?.isTracking == true
self.tableView?.isTracking == true
}
var isDecelerating: Bool {
self.scrollView?.isDecelerating == true
self.tableView?.isDecelerating == true
}
var isTopVisible: Bool {
guard let scrollView = scrollView else {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= 0.0
}
var isBottomVisible: Bool {
guard let scrollView = scrollView else {
return false
}
return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height)
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topTriggerHeight
}
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let isTopVisible = isTopVisible
if isTopVisible && isAtTop != isTopVisible {
scrollViewDidReachTop.send(())
}
isAtTop = isTopVisible
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
didEndScrolling.send(())
scrollViewDidRest.send(())
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerating: Bool) {
if decelerating == false {
didEndScrolling.send(())
scrollViewDidRest.send(())
}
}
// MARK: - Private
private var isBottomVisible: Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height)
}
}
// MARK: - Previews
@@ -191,6 +245,7 @@ struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
timelineController: MockRoomTimelineController())
RoomScreen(context: viewModel.context)
}
}

View File

@@ -13,14 +13,13 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
let timelineItems: [RoomTimelineViewProvider] =
[RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
var timelineItems: [RoomTimelineViewProvider] = [RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
func paginateBackwards(_ count: UInt) {
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineControllerError>) -> Void)) {
callbacks.send(.updatedTimelineItems)
}
}

View File

@@ -10,10 +10,6 @@ import Foundation
import Combine
import MatrixRustSDK
enum RoomTimelineControllerCallback {
case updatedTimelineItems
}
class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProvider
private var cancellables = Set<AnyCancellable>()
@@ -29,50 +25,62 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
guard let self = self else { return }
switch callback {
case .updatedMessages:
var newTimelineItems = [RoomTimelineViewProvider]()
var previousMessage: Message?
for message in self.timelineProvider.messages {
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
let areMessagesFromTheSameDay = self.haveSameDay(lhs: previousMessage, rhs: message)
let shouldAddSectionHeader = !areMessagesFromTheSameDay
if shouldAddSectionHeader {
let item = SeparatorRoomTimelineItem(id: timestamp.ISO8601Format(),
text: timestamp.formatted(date: .long, time: .omitted))
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
}
let areMessagesFromTheSameSender = (previousMessage?.sender() == message.sender())
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
let item = TextRoomTimelineItem(id: message.id(),
senderDisplayName: message.sender(),
text: message.content(),
timestamp: timestamp.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: shouldShowSenderDetails)
newTimelineItems.append(RoomTimelineViewProvider.text(item))
previousMessage = message
}
self.timelineItems = newTimelineItems
self.callbacks.send(.updatedTimelineItems)
case .addedMessage:
self.rebuildTimeline()
}
}.store(in: &cancellables)
}
func paginateBackwards(_ count: UInt) {
timelineProvider.paginateBackwards(count)
func paginateBackwards(_ count: UInt, callback: @escaping ((Result<Void, RoomTimelineControllerError>) -> Void)) {
timelineProvider.paginateBackwards(count) { [weak self] result in
switch result {
case .success:
callback(.success(()))
self?.rebuildTimeline()
case .failure:
callback(.failure(.generic))
}
}
}
// MARK: - Private
private func rebuildTimeline() {
var newTimelineItems = [RoomTimelineViewProvider]()
var previousMessage: Message?
for message in self.timelineProvider.messages {
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
let areMessagesFromTheSameDay = self.haveSameDay(lhs: previousMessage, rhs: message)
let shouldAddSectionHeader = !areMessagesFromTheSameDay
if shouldAddSectionHeader {
let item = SeparatorRoomTimelineItem(id: timestamp.ISO8601Format(),
text: timestamp.formatted(date: .long, time: .omitted))
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
}
let areMessagesFromTheSameSender = (previousMessage?.sender() == message.sender())
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
let item = TextRoomTimelineItem(id: message.id(),
senderDisplayName: message.sender(),
text: message.content(),
timestamp: timestamp.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: shouldShowSenderDetails)
newTimelineItems.append(RoomTimelineViewProvider.text(item))
previousMessage = message
}
self.timelineItems = newTimelineItems
self.callbacks.send(.updatedTimelineItems)
}
private func haveSameDay(lhs: Message?, rhs: Message?) -> Bool {
guard let lhs = lhs, let rhs = rhs else {
return false

View File

@@ -9,9 +9,17 @@
import Foundation
import Combine
enum RoomTimelineControllerCallback {
case updatedTimelineItems
}
enum RoomTimelineControllerError: Error {
case generic
}
protocol RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineViewProvider] { get }
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
func paginateBackwards(_ count: UInt)
func paginateBackwards(_ count: UInt, callback: @escaping ((Result<Void, RoomTimelineControllerError>) -> Void))
}

View File

@@ -11,15 +11,17 @@ import Combine
import MatrixRustSDK
enum RoomTimelineCallback {
case updatedMessages
case addedMessage
}
enum RoomTimelineError: Error {
case generic
}
class RoomTimelineProvider {
private let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
private var paginationCounter: UInt = 0
let callbacks = PassthroughSubject<RoomTimelineCallback, Never>()
private(set) var messages = [Message]()
@@ -32,24 +34,21 @@ class RoomTimelineProvider {
switch callback {
case .addedMessage(let message):
self.messages.append(message)
self.callbacks.send(.addedMessage)
case .updatedLastMessage:
break
}
self.callbacks.send(.updatedMessages)
}.store(in: &cancellables)
}
func paginateBackwards(_ count: UInt) {
func paginateBackwards(_ count: UInt, callback: ((Result<([Message]), RoomTimelineError>) -> Void)?) {
self.roomProxy.paginateBackwards(count: count) { result in
switch result {
case .success(let messages):
self.messages.insert(contentsOf: messages.reversed(), at: 0)
self.callbacks.send(.updatedMessages)
case .failure(let error):
MXLog.debug("Failed paginating backwards with error: \(error)")
self.callbacks.send(.updatedMessages)
callback?(.success((self.messages)))
case .failure:
callback?(.failure(.generic))
}
}
}