Prepare for event focus and forward pagination (#2745)

Neither are available yet.
This commit is contained in:
Doug
2024-04-25 18:32:33 +01:00
committed by GitHub
parent 8d66572cc9
commit 76e7de40b5
254 changed files with 1461 additions and 845 deletions
@@ -84,6 +84,7 @@ enum RoomScreenViewAction {
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
case paginateForwards
case timelineItemMenu(itemID: TimelineItemIdentifier)
case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
@@ -107,7 +108,15 @@ enum RoomScreenViewAction {
case presentCall
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one).
case focusLive
/// Remove the highlighted event without switching timeline.
///
/// This is useful when returning to the bottom of the live timeline
/// if `focusOnEventID` didn't use a detached timeline.
case clearFocussedEvent
}
enum RoomScreenComposerAction {
@@ -127,7 +136,7 @@ struct RoomScreenViewState: BindableState {
var showReadReceipts = false
var timelineStyle: TimelineStyle
var isEncryptedOneToOneRoom = false
var timelineViewState = TimelineViewState() // check the doc before changing this
var timelineViewState: TimelineViewState // check the doc before changing this
var ownUserID: String
@@ -217,8 +226,10 @@ struct RoomMemberState {
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
struct TimelineViewState {
var canBackPaginate = true
var isBackPaginating = true
var isLive = true
var paginationState = PaginationState.default
var focussedEventID: String?
// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@@ -232,4 +243,8 @@ struct TimelineViewState {
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
}
@@ -23,8 +23,9 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationEventLimit: UInt = 20
static let backPaginationPageSize: UInt = 50
static let paginationEventLimit: UInt16 = 20
static let detachedTimelineSize: UInt16 = 100
static let focusTimelineToastIndicatorID = "RoomScreenFocusTimelineToastIndicator"
static let toastErrorID = "RoomScreenToastError"
}
@@ -47,6 +48,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
private var paginateBackwardsTask: Task<Void, Never>?
private var paginateForwardsTask: Task<Void, Never>?
init(roomProxy: RoomProxyProtocol,
focussedEventID: String? = nil,
@@ -86,6 +88,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomAvatarURL: roomProxy.avatarURL,
timelineStyle: appSettings.timelineStyle,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineViewState(focussedEventID: focussedEventID),
ownUserID: roomProxy.ownUserID,
hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init(reactionsCollapsed: [:])),
@@ -170,6 +173,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await timelineController.cancelSending(itemID: itemID) }
case .paginateBackwards:
paginateBackwards()
case .paginateForwards:
paginateForwards()
case .poll(let pollAction):
processPollAction(pollAction)
case .audio(let audioAction):
@@ -179,8 +184,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .showReadReceipts(itemID: let itemID):
showReadReceipts(for: itemID)
case .focusOnEventID(let eventID):
// TODO: .. something
break
Task { await focusOnEvent(eventID: eventID) }
case .focusLive:
focusLive()
case .clearFocussedEvent:
state.timelineViewState.focussedEventID = nil
}
}
@@ -214,10 +222,31 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
func focusOnEvent(eventID: String) async { }
func focusOnEvent(eventID: String) async {
if state.timelineViewState.hasLoadedItem(with: eventID) {
state.timelineViewState.focussedEventID = eventID
return
}
showFocusLoadingIndicator()
defer { hideFocusLoadingIndicator() }
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
case .success:
state.timelineViewState.focussedEventID = eventID
case .failure(let error):
MXLog.error("Failed to focus on event \(eventID)")
displayError(.toast(L10n.commonFailed))
}
}
// MARK: - Private
private func focusLive() {
timelineController.focusLive()
state.timelineViewState.focussedEventID = nil
}
private func attach(_ attachment: ComposerAttachmentType) {
switch attachment {
case .camera:
@@ -297,14 +326,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch callback {
case .updatedTimelineItems:
self.buildTimelineViews()
case .canBackPaginate(let canBackPaginate):
if self.state.timelineViewState.canBackPaginate != canBackPaginate {
self.state.timelineViewState.canBackPaginate = canBackPaginate
buildTimelineViews()
case .paginationState(let paginationState):
if state.timelineViewState.paginationState != paginationState {
state.timelineViewState.paginationState = paginationState
}
case .isBackPaginating(let isBackPaginating):
if self.state.timelineViewState.isBackPaginating != isBackPaginating {
self.state.timelineViewState.isBackPaginating = isBackPaginating
case .isLive(let isLive):
if state.timelineViewState.isLive != isLive {
state.timelineViewState.isLive = isLive
// Remove the event highlight *only* when transitioning from non-live to live.
if isLive, state.timelineViewState.focussedEventID != nil {
state.timelineViewState.focussedEventID = nil
}
}
}
}
@@ -414,7 +448,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return
}
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
switch await timelineController.paginateBackwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayError(.toast(L10n.errorFailedLoadingMessages))
default:
@@ -424,6 +458,31 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
private func paginateForwards() {
guard paginateForwardsTask == nil else {
return
}
paginateForwardsTask = Task { [weak self] in
guard let self else {
return
}
switch await timelineController.paginateForwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayError(.toast(L10n.errorFailedLoadingMessages))
default:
break
}
if state.timelineViewState.paginationState.forward == .timelineStartReached {
focusLive()
}
paginateForwardsTask = nil
}
}
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return }
@@ -636,6 +695,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - User Indicators
private func showFocusLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Constants.focusTimelineToastIndicatorID,
type: .toast(progress: .indeterminate),
title: L10n.commonLoading,
persistent: true))
}
private func hideFocusLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Constants.focusTimelineToastIndicatorID)
}
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
@@ -662,6 +732,7 @@ private extension RoomProxyProtocol {
extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(name: "Preview room")),
focussedEventID: nil,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -674,14 +745,23 @@ extension RoomScreenViewModel {
}
private struct RoomContextKey: EnvironmentKey {
@MainActor
static let defaultValue = RoomScreenViewModel.mock.context
@MainActor static let defaultValue = RoomScreenViewModel.mock.context
}
private struct FocussedEventID: EnvironmentKey {
static let defaultValue: String? = nil
}
extension EnvironmentValues {
/// Used to access and inject and access the room context without observing it
/// Used to access and inject the room context without observing it
var roomContext: RoomScreenViewModel.Context {
get { self[RoomContextKey.self] }
set { self[RoomContextKey.self] = newValue }
}
/// An event ID which will be non-nil when a timeline item should show as focussed.
var focussedEventID: String? {
get { self[FocussedEventID.self] }
set { self[FocussedEventID.self] = newValue }
}
}
@@ -92,17 +92,18 @@ struct RoomScreen: View {
}
private var timeline: some View {
UITimelineView()
TimelineView()
.id(context.viewState.roomID)
.environmentObject(context)
.environment(\.timelineStyle, context.viewState.timelineStyle)
.environment(\.focussedEventID, context.viewState.timelineViewState.focussedEventID)
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton
}
}
private var scrollToBottomButton: some View {
Button { context.viewState.timelineViewState.scrollToBottomPublisher.send(()) } label: {
Button(action: scrollToBottom) {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
@@ -117,9 +118,21 @@ struct RoomScreen: View {
}
.padding()
}
.opacity(context.isScrolledToBottom ? 0.0 : 1.0)
.accessibilityHidden(context.isScrolledToBottom)
.animation(.elementDefault, value: context.isScrolledToBottom)
.opacity(isAtBottomAndLive ? 0.0 : 1.0)
.accessibilityHidden(isAtBottomAndLive)
.animation(.elementDefault, value: isAtBottomAndLive)
}
private var isAtBottomAndLive: Bool {
context.isScrolledToBottom && context.viewState.timelineViewState.isLive
}
private func scrollToBottom() {
if context.viewState.timelineViewState.isLive {
context.viewState.timelineViewState.scrollToBottomPublisher.send(())
} else {
context.send(viewAction: .focusLive)
}
}
@ViewBuilder
@@ -22,12 +22,14 @@ import Compound
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
@Environment(\.focussedEventID) private var focussedEventID
let timelineItem: EventBasedTimelineItemProtocol
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
@ViewBuilder let content: () -> Content
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID }
/// The base padding applied to bubbles on either side.
///
@@ -71,6 +73,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.padding(.leading, bubbleAvatarPadding)
}
}
.padding(TimelineStyle.bubbles.rowInsets)
.highlightedTimelineItem(isFocussed)
}
@ViewBuilder
@@ -528,8 +532,6 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
VStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in
RoomTimelineItemView(viewState: viewState)
.padding(TimelineStyle.bubbles.rowInsets)
// Insets added in the table view cells
}
}
}
@@ -20,10 +20,13 @@ import SwiftUI
struct TimelineItemPlainStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
@Environment(\.focussedEventID) private var focussedEventID
let timelineItem: EventBasedTimelineItemProtocol
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
@ViewBuilder let content: () -> Content
private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID }
var body: some View {
VStack(alignment: .trailing, spacing: 0) {
@@ -38,6 +41,8 @@ struct TimelineItemPlainStylerView<Content: View>: View {
TimelineItemStatusView(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus)
.environmentObject(context)
}
.padding(TimelineStyle.plain.rowInsets)
.highlightedTimelineItem(isFocussed)
}
@ViewBuilder
@@ -151,7 +156,6 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineItemView(viewState: .init(item: item, groupStyle: .single))
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
}
}
.environment(\.timelineStyle, .plain)
@@ -0,0 +1,85 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
extension View {
func highlightedTimelineItem(_ isHighlighted: Bool) -> some View {
modifier(HighlightedTimelineItemModifier(isHighlighted: isHighlighted))
}
}
private struct HighlightedTimelineItemModifier: ViewModifier {
let isHighlighted: Bool
func body(content: Content) -> some View {
content
.background {
if isHighlighted {
VStack(spacing: 0) {
Color.compound._bgBubbleHighlighted
LinearGradient(colors: [.compound._bgBubbleHighlighted, .clear],
startPoint: .top,
endPoint: .bottom)
.frame(maxHeight: 200)
.layoutPriority(1)
}
.overlay(alignment: .top) {
Color.compound.bgAccentRest
.frame(height: 1)
}
}
}
}
}
struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
ScrollView {
VStack(spacing: 16) {
Bubble(text: "Hello 👋")
.highlightedTimelineItem(true)
Bubble(text: "Not highlighted")
.highlightedTimelineItem(false)
Bubble(text: """
Bacon ipsum dolor amet brisket bacon hamburger filet mignon ham hock, capicola meatloaf corned beef tongue. Ribeye filet mignon shoulder drumstick doner shank. Landjaeger shankle chislic brisket short loin pig. Frankfurter sirloin jerky bresaola tri-tip cow buffalo. Beef tongue shankle venison, sirloin boudin biltong ham hock corned beef. Sirloin shankle pork belly, strip steak pancetta brisket flank ribeye cow chislic. Pork ham landjaeger, pastrami beef sausage capicola meatball.
Cow brisket bresaola, burgdoggen cupim turducken sirloin andouille shankle sausage jerky chicken pig. Tail capicola landjaeger frankfurter. Kevin pancetta brisket spare ribs, sausage chuck tail pork. Ground round boudin chuck tri-tip corned beef. Pork belly ham bresaola tail, pork chop meatloaf biltong filet mignon strip steak ribeye boudin shoulder frankfurter.
""",
isOutgoing: true)
.highlightedTimelineItem(true)
}
}
}
struct Bubble: View {
let text: String
var isOutgoing = false
var body: some View {
Text(text)
.padding(10)
.background(isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming,
in: RoundedRectangle(cornerRadius: 12))
.padding(isOutgoing ? .leading : .trailing, 40)
.frame(maxWidth: .infinity, alignment: isOutgoing ? .trailing : .leading)
.padding(12)
}
}
}
@@ -89,7 +89,6 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
ScrollView {
VStack(spacing: 8) {
states
.padding(.horizontal)
}
}
.environmentObject(viewModel.context)
@@ -98,7 +97,6 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
ScrollView {
VStack(spacing: 0) {
states
.padding(.horizontal)
}
}
.environment(\.timelineStyle, .plain)
@@ -28,7 +28,7 @@ struct PaginationIndicatorRoomTimelineView: View {
struct PaginationIndicatorRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = PaginationIndicatorRoomTimelineItem()
let item = PaginationIndicatorRoomTimelineItem(position: .start)
PaginationIndicatorRoomTimelineView(timelineItem: item)
}
}
@@ -64,7 +64,6 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
sender: .init(id: "", displayName: "Alice"),
content: .init(body: "This is a message"))), groupStyle: .single))
}
.padding(.horizontal, 8)
.environmentObject(viewModel.context)
}
}
@@ -15,6 +15,7 @@
//
import Combine
import Compound
import SwiftUI
import OrderedCollections
@@ -52,33 +53,71 @@ class TypingMembersObservableObject: ObservableObject {
/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1).
/// Also this TableViewController uses a **flipped tableview**
class TimelineTableViewController: UIViewController {
private let coordinator: UITimelineView.Coordinator
private let coordinator: TimelineView.Coordinator
private let tableView = UITableView(frame: .zero, style: .plain)
var timelineStyle: TimelineStyle
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>() {
didSet {
if !isLive, isDraggingScrollView {
// Forward pagination doesn't play well with the user scrolling, so we wait for it to stop.
hasPendingItems = true
return
}
applySnapshot()
if timelineItemsDictionary.isEmpty {
paginateBackwardsPublisher.send()
paginatePublisher.send()
}
sendLastVisibleItemReadReceipt()
}
}
/// Whether or not the timeline has more messages to back paginate.
var canBackPaginate = true
/// There are pending items in `timelineItemsDictionary` that haven't been applied to the data source.
var hasPendingItems = false
/// Whether or not the timeline is waiting for more messages to be added to the top.
var isBackPaginating = false {
/// The user is dragging the scroll view (or it is still decelerating after a drag).
var isDraggingScrollView = false {
didSet {
// Paginate again if the threshold hasn't been satisfied.
paginateBackwardsPublisher.send(())
if !isDraggingScrollView, hasPendingItems {
hasPendingItems = false
applySnapshot()
}
}
}
/// Whether or not the current timeline is live or built around an event ID.
var isLive = true {
didSet {
// Update isScrolledToBottom when switching back to a live timeline.
if isLive { scrollViewDidScroll(tableView) }
}
}
/// The state of pagination (in both directions) of the current timeline.
var paginationState: PaginationState = .default {
didSet {
// Paginate again if the threshold hasn't been satisfied.
paginatePublisher.send(())
}
}
/// The ID of the focussed event if opening the room to an event permalink.
var focussedEventID: String? {
didSet {
guard let focussedEventID else { return }
focussedEventNeedsDisplay = true
scrollToItem(eventID: focussedEventID, animated: false)
}
}
/// Whether the timeline should scroll to `focussedEventID` when that item is added to the data source.
/// This is necessary as the focussed event can be set before the timeline builder has built its item.
var focussedEventNeedsDisplay = false
/// Used to hold an observable object that the typing indicator can use
let typingMembers = TypingMembersObservableObject(members: [])
@@ -103,12 +142,13 @@ class TimelineTableViewController: UIViewController {
/// A publisher used to throttle back pagination requests.
///
/// Our view actions get wrapped in a `Task` so it is possible that a second call in
/// quick succession can execute before ``isBackPaginating`` becomes `true`.
private let paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
/// quick succession can execute before ``paginationState`` acknowledges that
/// pagination is in progress.
private let paginatePublisher = PassthroughSubject<Void, Never>()
/// Whether or not the view has been shown on screen yet.
private var hasAppearedOnce = false
init(coordinator: UITimelineView.Coordinator,
init(coordinator: TimelineView.Coordinator,
timelineStyle: TimelineStyle,
isScrolledToBottom: Binding<Bool>,
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
@@ -133,14 +173,17 @@ class TimelineTableViewController: UIViewController {
scrollToBottomPublisher
.sink { [weak self] _ in
self?.scrollToBottom(animated: true)
guard let self else { return }
scrollToNewestItem(animated: true)
coordinator.send(viewAction: .clearFocussedEvent)
}
.store(in: &cancellables)
paginateBackwardsPublisher
paginatePublisher
.collect(.byTime(DispatchQueue.main, 0.1))
.sink { [weak self] _ in
self?.paginateBackwardsIfNeeded()
self?.paginateIfNeeded()
}
.store(in: &cancellables)
@@ -164,7 +207,7 @@ class TimelineTableViewController: UIViewController {
guard !hasAppearedOnce else { return }
tableView.contentOffset.y = -1
hasAppearedOnce = true
paginateBackwardsPublisher.send()
paginatePublisher.send()
}
override func viewWillLayoutSubviews() {
@@ -210,6 +253,7 @@ class TimelineTableViewController: UIViewController {
guard let viewState else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
RoomTimelineItemView(viewState: viewState)
.id(id)
@@ -217,7 +261,7 @@ class TimelineTableViewController: UIViewController {
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.environment(\.roomContext, coordinator.context)
}
.margins(.all, self.timelineStyle.rowInsets)
.margins(.all, 0) // Margins are handled in the stylers
.minSize(height: 1)
.background(Color.clear)
@@ -257,42 +301,67 @@ class TimelineTableViewController: UIViewController {
let currentSnapshot = dataSource.snapshot()
MXLog.verbose("DIFF: \(snapshot.itemIdentifiers.difference(from: currentSnapshot.itemIdentifiers))")
// We only animate when new items come at the end of the timeline, ignoring transitions through empty.
let animated = currentSnapshot.sectionIdentifiers.contains(.main) &&
snapshot.sectionIdentifiers.contains(.main) &&
currentSnapshot.numberOfItems(inSection: .main) > 0 &&
snapshot.numberOfItems(inSection: .main) > 0 &&
snapshot.itemIdentifiers(inSection: .main).first != currentSnapshot.itemIdentifiers(inSection: .main).first
// We only animate when new items come at the end of a live timeline, ignoring transitions through empty.
let newestItemIdentifier = snapshot.mainItemIdentifiers.first
let currentNewestItemIdentifier = currentSnapshot.mainItemIdentifiers.first
let newestItemIDChanged = snapshot.numberOfMainItems > 0 && currentSnapshot.numberOfMainItems > 0 && newestItemIdentifier != currentNewestItemIdentifier
let animated = isLive && newestItemIDChanged
let layout: Layout? = if !isLive, newestItemIDChanged {
snapshotLayout()
} else {
nil
}
dataSource.apply(snapshot, animatingDifferences: animated)
if let focussedEventID, focussedEventNeedsDisplay {
scrollToItem(eventID: focussedEventID, animated: false)
} else if let layout {
restoreLayout(layout)
}
}
/// Scrolls to the bottom of the timeline.
private func scrollToBottom(animated: Bool) {
/// Scrolls to the newest item in the timeline.
private func scrollToNewestItem(animated: Bool) {
guard !timelineItemsIDs.isEmpty else {
return
}
tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: animated)
}
/// Scrolls to the top of the timeline.
private func scrollToTop(animated: Bool) {
/// Scrolls to the oldest item in the timeline.
private func scrollToOldestItem(animated: Bool) {
guard !timelineItemsIDs.isEmpty else {
return
}
tableView.scrollToRow(at: IndexPath(item: timelineItemsIDs.count - 1, section: 1), at: .bottom, animated: animated)
}
/// Checks whether or a backwards pagination is needed and requests one if so.
/// Scrolls to the item with the corresponding event ID if loaded in the timeline.
private func scrollToItem(eventID: String, animated: Bool) {
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == focussedEventID }),
let indexPath = dataSource?.indexPath(for: kvPair.key) {
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
focussedEventNeedsDisplay = false
}
}
/// Checks whether or not pagination is needed in either direction and requests one if so.
///
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
private func paginateBackwardsIfNeeded() {
guard canBackPaginate,
!isBackPaginating,
tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0
else { return }
/// **Note:** Prefer not to call this directly, instead using ``paginatePublisher`` to throttle requests.
private func paginateIfNeeded() {
guard !hasPendingItems else { return }
coordinator.send(viewAction: .paginateBackwards)
if paginationState.backward == .idle,
tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0 {
coordinator.send(viewAction: .paginateBackwards)
}
if !isLive,
paginationState.forward == .idle,
tableView.contentOffset.y < tableView.visibleSize.height {
coordinator.send(viewAction: .paginateForwards)
}
}
private func sendLastVisibleItemReadReceipt() {
@@ -316,7 +385,7 @@ class TimelineTableViewController: UIViewController {
extension TimelineTableViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
paginateBackwardsPublisher.send(())
paginatePublisher.send(())
// Dispatch to fix runtime warning about making changes during a view update.
DispatchQueue.main.async { [weak self] in
@@ -335,18 +404,26 @@ extension TimelineTableViewController: UITableViewDelegate {
scrollView.contentOffset.y = -1
}
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
scrollToTop(animated: true)
scrollToOldestItem(animated: true)
return false
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDraggingScrollView = true
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
sendLastVisibleItemReadReceipt()
if !decelerate {
isDraggingScrollView = false
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
sendLastVisibleItemReadReceipt()
isDraggingScrollView = false
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
@@ -354,7 +431,7 @@ extension TimelineTableViewController: UITableViewDelegate {
}
}
// MARK: - Layout Types
// MARK: - Layout
extension TimelineTableViewController {
/// The sections of the table view used in the diffable data source.
@@ -362,4 +439,67 @@ extension TimelineTableViewController {
case main
case typingIndicator
}
/// A representation of the table's layout based on a particular item.
private struct Layout {
let id: TimelineItemIdentifier
let frame: CGRect
}
/// The current layout of the table, based on the newest timeline item.
private func snapshotLayout() -> Layout? {
guard let newestItemID = newestVisibleItemID(),
let newestCellFrame = cellFrame(for: newestItemID.timelineID) else {
return nil
}
return Layout(id: newestItemID, frame: newestCellFrame)
}
/// Restores the timeline's layout from an old snapshot.
private func restoreLayout(_ layout: Layout) {
if let indexPath = dataSource?.indexPath(for: layout.id.timelineID) {
// Scroll the item into view.
tableView.scrollToRow(at: indexPath, at: .top, animated: false)
// Remove any unwanted offset that was added by scrollToRow.
if let frame = cellFrame(for: layout.id.timelineID) {
let deltaY = frame.maxY - layout.frame.maxY
if deltaY != 0 {
tableView.contentOffset.y -= deltaY
}
}
}
}
/// Returns the frame of the cell for a particular timeline item.
private func cellFrame(for id: String) -> CGRect? {
guard let timelineCell = tableView.visibleCells.first(where: { ($0 as? TimelineItemCell)?.item?.id == id }) else {
return nil
}
return tableView.convert(timelineCell.frame, to: tableView.superview)
}
/// The item ID of the newest visible item in the timeline.
private func newestVisibleItemID() -> TimelineItemIdentifier? {
guard let timelineCell = tableView.visibleCells.first(where: {
guard let cell = $0 as? TimelineItemCell else { return false }
return !(cell.item?.type is PaginationIndicatorRoomTimelineItem)
}) else {
return nil
}
return (timelineCell as? TimelineItemCell)?.item?.identifier
}
}
private extension NSDiffableDataSourceSnapshot<TimelineTableViewController.TimelineSection, String> {
var numberOfMainItems: Int {
guard sectionIdentifiers.contains(.main) else { return 0 }
return numberOfItems(inSection: .main)
}
var mainItemIdentifiers: [String] {
guard sectionIdentifiers.contains(.main) else { return [] }
return itemIdentifiers(inSection: .main)
}
}
@@ -18,7 +18,7 @@ import SwiftUI
import WysiwygComposer
/// A table view wrapper that displays the timeline of a room.
struct UITimelineView: UIViewControllerRepresentable {
struct TimelineView: UIViewControllerRepresentable {
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
@Environment(\.timelineStyle) private var timelineStyle
@@ -56,11 +56,14 @@ struct UITimelineView: UIViewControllerRepresentable {
if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary {
tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary
}
if tableViewController.canBackPaginate != context.viewState.timelineViewState.canBackPaginate {
tableViewController.canBackPaginate = context.viewState.timelineViewState.canBackPaginate
if tableViewController.paginationState != context.viewState.timelineViewState.paginationState {
tableViewController.paginationState = context.viewState.timelineViewState.paginationState
}
if tableViewController.isBackPaginating != context.viewState.timelineViewState.isBackPaginating {
tableViewController.isBackPaginating = context.viewState.timelineViewState.isBackPaginating
if tableViewController.isLive != context.viewState.timelineViewState.isLive {
tableViewController.isLive = context.viewState.timelineViewState.isLive
}
if tableViewController.focussedEventID != context.viewState.timelineViewState.focussedEventID {
tableViewController.focussedEventID = context.viewState.timelineViewState.focussedEventID
}
if tableViewController.typingMembers.members != context.viewState.typingMembers {
@@ -76,7 +79,7 @@ struct UITimelineView: UIViewControllerRepresentable {
// MARK: - Previews
struct UITimelineView_Previews: PreviewProvider, TestablePreview {
struct TimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(name: "Preview room")),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),