mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
committed by
Stefan Ceriu
parent
beaf9a8dbf
commit
3c8daebfe1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user