Delay rendering bottom timeline items when scrolled up and avoid content shifts

- builds on top of langleyd/timeline_defer_new_items
- improve animations
This commit is contained in:
Stefan Ceriu
2023-07-31 13:34:54 +03:00
committed by Stefan Ceriu
parent beaf9a8dbf
commit 3c8daebfe1
9 changed files with 87 additions and 33 deletions
@@ -80,6 +80,8 @@ enum RoomScreenViewAction {
case retrySend(itemID: TimelineItemIdentifier) case retrySend(itemID: TimelineItemIdentifier)
case cancelSend(itemID: TimelineItemIdentifier) case cancelSend(itemID: TimelineItemIdentifier)
case scrolledToBottom
} }
struct RoomScreenViewState: BindableState { struct RoomScreenViewState: BindableState {
@@ -109,7 +111,7 @@ struct RoomScreenViewStateBindings {
var composerText: String var composerText: String
var composerFocused: Bool var composerFocused: Bool
var scrollToBottomButtonVisible = false var isScrolledToBottom = true
var showAttachmentPopover = false { var showAttachmentPopover = false {
didSet { didSet {
composerFocused = false composerFocused = false
@@ -185,11 +187,14 @@ struct TimelineViewState {
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>() var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
var renderedTimelineIDs = [String]()
var pendingTimelineIDs = [String]()
var timelineIDs: [String] { var timelineIDs: [String] {
itemsDictionary.keys.elements itemsDictionary.keys.elements
} }
var itemViewStates: [RoomTimelineItemViewState] { var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements renderedTimelineIDs.compactMap { itemsDictionary[$0] }
} }
} }
@@ -138,6 +138,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await handleCancelSend(itemID: itemID) } Task { await handleCancelSend(itemID: itemID) }
case .paginateBackwards: case .paginateBackwards:
paginateBackwards() paginateBackwards()
case .scrolledToBottom:
if state.swiftUITimelineEnabled {
renderPendingTimelineItems()
}
} }
} }
@@ -276,29 +280,34 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
if itemGroup.count == 1 { if itemGroup.count == 1 {
if let firstItem = itemGroup.first { if let firstItem = itemGroup.first {
timelineItemsDictionary.updateValue(updateViewstate(item: firstItem, groupStyle: .single), timelineItemsDictionary.updateValue(updateViewState(item: firstItem, groupStyle: .single),
forKey: firstItem.id.timelineID) forKey: firstItem.id.timelineID)
} }
} else { } else {
for (index, item) in itemGroup.enumerated() { for (index, item) in itemGroup.enumerated() {
if index == 0 { if index == 0 {
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .first), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first),
forKey: item.id.timelineID) forKey: item.id.timelineID)
} else if index == itemGroup.count - 1 { } else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .last), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last),
forKey: item.id.timelineID) forKey: item.id.timelineID)
} else { } else {
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .middle), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle),
forKey: item.id.timelineID) forKey: item.id.timelineID)
} }
} }
} }
} }
state.timelineViewState.itemsDictionary = timelineItemsDictionary // The SwiftUI scroll view needs special handling, see `selectivellyUpdateTimelineItems`
if state.swiftUITimelineEnabled {
selectivelyUpdateTimelineItems(timelineItemsDictionary: timelineItemsDictionary)
} else {
state.timelineViewState.itemsDictionary = timelineItemsDictionary
}
} }
private func updateViewstate(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState { private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] { if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] {
timelineItemViewState.groupStyle = groupStyle timelineItemViewState.groupStyle = groupStyle
timelineItemViewState.type = .init(item: item) timelineItemViewState.type = .init(item: item)
@@ -308,6 +317,41 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
/// With the timeline scroll being reversed, introducing items at it's top (i.e. bottom now) will make the content move upwards, which is unwanted when
/// reading history. Delay rendering new items until it reaches the bottom again.
private func selectivelyUpdateTimelineItems(timelineItemsDictionary: OrderedDictionary<String, RoomTimelineItemViewState>) {
var timelineViewState = state.timelineViewState
let newItemIdentifiers = Array(timelineItemsDictionary.keys)
if !state.bindings.isScrolledToBottom,
let lastItemIdentifier = state.timelineViewState.renderedTimelineIDs.last,
let newLastItemIdentifierIndex = newItemIdentifiers.firstIndex(where: { $0 == lastItemIdentifier }) {
timelineViewState.pendingTimelineIDs = Array(newItemIdentifiers.dropFirst(newLastItemIdentifierIndex + 1))
timelineViewState.renderedTimelineIDs = Array(newItemIdentifiers.dropLast(newItemIdentifiers.count - (newLastItemIdentifierIndex + 1)))
} else {
// Otherwise just render everything normally
timelineViewState.renderedTimelineIDs = Array(timelineItemsDictionary.keys)
}
timelineViewState.itemsDictionary = timelineItemsDictionary
state.timelineViewState = timelineViewState
}
private func renderPendingTimelineItems() {
// Render pending timeline items when the scroll view reaches the bottom again
guard state.bindings.isScrolledToBottom,
state.timelineViewState.pendingTimelineIDs.count > 0 else {
return
}
var newTimelineViewState = state.timelineViewState
newTimelineViewState.renderedTimelineIDs = state.timelineViewState.renderedTimelineIDs + state.timelineViewState.pendingTimelineIDs
newTimelineViewState.pendingTimelineIDs = []
state.timelineViewState = newTimelineViewState
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool { private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem { if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false return false
@@ -80,6 +80,11 @@ struct RoomScreen: View {
context.send(viewAction: .cancelSend(itemID: info.itemID)) context.send(viewAction: .cancelSend(itemID: info.itemID))
} }
} }
.onChange(of: context.isScrolledToBottom) { isScrolledToBottom in
if isScrolledToBottom {
context.send(viewAction: .scrolledToBottom)
}
}
} }
private var timeline: some View { private var timeline: some View {
@@ -94,7 +99,7 @@ struct RoomScreen: View {
private var timelineSwitch: some View { private var timelineSwitch: some View {
if context.viewState.swiftUITimelineEnabled { if context.viewState.swiftUITimelineEnabled {
TimelineView(viewState: context.viewState.timelineViewState, TimelineView(viewState: context.viewState.timelineViewState,
scrollToBottomButtonVisible: $context.scrollToBottomButtonVisible) { isScrolledToBottom: $context.isScrolledToBottom) {
context.send(viewAction: .paginateBackwards) context.send(viewAction: .paginateBackwards)
} }
} else { } else {
@@ -121,9 +126,9 @@ struct RoomScreen: View {
} }
.padding() .padding()
} }
.opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0) .opacity(context.isScrolledToBottom ? 0.0 : 1.0)
.accessibilityHidden(!context.scrollToBottomButtonVisible) .accessibilityHidden(context.isScrolledToBottom)
.animation(.elementDefault, value: context.scrollToBottomButtonVisible) .animation(.elementDefault, value: context.isScrolledToBottom)
} }
private var messageComposer: some View { private var messageComposer: some View {
@@ -54,8 +54,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
Rectangle() Rectangle()
.foregroundColor(.global.melon) .foregroundColor(.global.melon)
.frame(width: 4.0) .frame(width: 4.0)
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
timelineItemReplyDetails: replyDetails)
} }
} }
@@ -68,7 +68,7 @@ class TimelineTableViewController: UIViewController {
var contextMenuActionProvider: (@MainActor (_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions?)? var contextMenuActionProvider: (@MainActor (_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions?)?
@Binding private var scrollToBottomButtonVisible: Bool @Binding private var isScrolledToBottom: Bool
private var timelineItemsIDs: [String] { private var timelineItemsIDs: [String] {
timelineItemsDictionary.keys.elements.reversed() timelineItemsDictionary.keys.elements.reversed()
@@ -90,11 +90,11 @@ class TimelineTableViewController: UIViewController {
init(coordinator: UITimelineView.Coordinator, init(coordinator: UITimelineView.Coordinator,
timelineStyle: TimelineStyle, timelineStyle: TimelineStyle,
scrollToBottomButtonVisible: Binding<Bool>, isScrolledToBottom: Binding<Bool>,
scrollToBottomPublisher: PassthroughSubject<Void, Never>) { scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
self.coordinator = coordinator self.coordinator = coordinator
self.timelineStyle = timelineStyle self.timelineStyle = timelineStyle
_scrollToBottomButtonVisible = scrollToBottomButtonVisible _isScrolledToBottom = isScrolledToBottom
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@@ -253,11 +253,11 @@ extension TimelineTableViewController: UITableViewDelegate {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
let scrollToBottomButtonVisible = scrollView.contentOffset.y > 0 let isScrolledToBottom = scrollView.contentOffset.y <= 0
// Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling. // Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
if self.scrollToBottomButtonVisible != scrollToBottomButtonVisible { if self.isScrolledToBottom != isScrolledToBottom {
self.scrollToBottomButtonVisible = scrollToBottomButtonVisible self.isScrolledToBottom = isScrolledToBottom
} }
} }
@@ -24,7 +24,7 @@ struct UITimelineView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> TimelineTableViewController { func makeUIViewController(context: Context) -> TimelineTableViewController {
let tableViewController = TimelineTableViewController(coordinator: context.coordinator, let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
timelineStyle: timelineStyle, timelineStyle: timelineStyle,
scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible, isScrolledToBottom: $viewModelContext.isScrolledToBottom,
scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher) scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher)
return tableViewController return tableViewController
} }
@@ -21,7 +21,7 @@ import SwiftUIIntrospect
struct TimelineView: View { struct TimelineView: View {
let viewState: TimelineViewState let viewState: TimelineViewState
@Binding var scrollToBottomButtonVisible: Bool @Binding var isScrolledToBottom: Bool
let paginationAction: () -> Void let paginationAction: () -> Void
@Environment(\.timelineStyle) private var timelineStyle @Environment(\.timelineStyle) private var timelineStyle
@@ -61,17 +61,17 @@ struct TimelineView: View {
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
} }
.overlay(scrollToBottomButton, alignment: .bottomTrailing) .overlay(scrollToBottomButton, alignment: .bottomTrailing)
.animation(.elementDefault, value: viewState.timelineIDs)
.onReceive(scrollViewAdapter.didScroll) { _ in .onReceive(scrollViewAdapter.didScroll) { _ in
guard let scrollView = scrollViewAdapter.scrollView else { guard let scrollView = scrollViewAdapter.scrollView else {
return return
} }
let offset = scrollView.contentOffset.y + scrollView.contentInset.top let offset = scrollView.contentOffset.y + scrollView.contentInset.top
// We give it a bit of tollerance which solves the issue when of it being displayed when the keyboard appears let isScrolledToBottom = offset <= 0
let scrollToBottomButtonVisibleValue = offset > 5
if scrollToBottomButtonVisibleValue != scrollToBottomButtonVisible { // Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
scrollToBottomButtonVisible = scrollToBottomButtonVisibleValue if self.isScrolledToBottom != isScrolledToBottom {
self.isScrolledToBottom = isScrolledToBottom
} }
// Allows the scroll to top to work properly // Allows the scroll to top to work properly
@@ -100,6 +100,7 @@ struct TimelineView: View {
.scaleEffect(x: 1, y: -1) .scaleEffect(x: 1, y: -1)
} }
} }
.animation(.elementDefault, value: viewState.itemViewStates)
topPin topPin
} }
} }
@@ -138,9 +139,9 @@ struct TimelineView: View {
} }
.padding() .padding()
} }
.opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) .opacity(isScrolledToBottom ? 0.0 : 1.0)
.accessibilityHidden(!scrollToBottomButtonVisible) .accessibilityHidden(isScrolledToBottom)
.animation(.elementDefault, value: scrollToBottomButtonVisible) .animation(.elementDefault, value: isScrolledToBottom)
} }
private func paginateBackwardsIfNeeded() { private func paginateBackwardsIfNeeded() {
@@ -23,7 +23,7 @@ struct RoomEventStringBuilder {
self.stateEventStringBuilder = stateEventStringBuilder self.stateEventStringBuilder = stateEventStringBuilder
} }
// swiftlint:disable:next cyclomatic_complexity function_body_length // swiftlint:disable:next cyclomatic_complexity
func buildAttributedString(for eventItemProxy: EventTimelineItemProxy) -> AttributedString? { func buildAttributedString(for eventItemProxy: EventTimelineItemProxy) -> AttributedString? {
let sender = eventItemProxy.sender let sender = eventItemProxy.sender
let isOutgoing = eventItemProxy.isOwn let isOutgoing = eventItemProxy.isOwn