From a2748497c7097b39731e593c419d7e104b665781 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 15 Mar 2022 13:06:50 +0200 Subject: [PATCH] Rewrote timeline back pagination on top of direct access to the underlying UITableView. --- .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 7 +- .../Screens/RoomScreen/View/RoomScreen.swift | 245 +++++++++++------- .../Timeline/MockRoomTimelineController.swift | 15 +- .../Timeline/RoomTimelineController.swift | 88 ++++--- .../RoomTimelineControllerProtocol.swift | 10 +- .../Timeline/RoomTimelineProvider.swift | 21 +- 7 files changed, 230 insertions(+), 157 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 69e346ec7..ac4217f23 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -29,4 +29,5 @@ enum RoomScreenViewAction { struct RoomScreenViewState: BindableState { var roomTitle: String = "" var timelineItems: [RoomTimelineViewProvider] = [] + var isBackPaginating = false } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index c65a4d982..bca369fe0 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -24,7 +24,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel() + 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() + let scrollViewDidReachTop = PassthroughSubject() 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) } } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 0da6b81a9..436b414a2 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -13,14 +13,13 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() - 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)) { + callbacks.send(.updatedTimelineItems) } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index f03c20b3e..8119dbade 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -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() @@ -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)) { + 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 diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index a71ef4a4d..028ea1b0e 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -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 { get } - func paginateBackwards(_ count: UInt) + func paginateBackwards(_ count: UInt, callback: @escaping ((Result) -> Void)) } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index c06de91ba..9794afd1a 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -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() - private var paginationCounter: UInt = 0 - let callbacks = PassthroughSubject() 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)) } } }