mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-03-14 09:45:42 +00:00
ios: scrolling improvements (#5746)
Some checks are pending
build / prepare-release (push) Waiting to run
build / build-macos-latest-9.6.3 (push) Blocked by required conditions
build / build-macos-13-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-20.04-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-22.04-9.6.3 (push) Blocked by required conditions
build / build-windows-latest-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-20.04-8.10.7 (push) Blocked by required conditions
Some checks are pending
build / prepare-release (push) Waiting to run
build / build-macos-latest-9.6.3 (push) Blocked by required conditions
build / build-macos-13-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-20.04-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-22.04-9.6.3 (push) Blocked by required conditions
build / build-windows-latest-9.6.3 (push) Blocked by required conditions
build / build-ubuntu-20.04-8.10.7 (push) Blocked by required conditions
* ios: scrolling improvements * changes * fixes * fix * private --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
parent
45c7c6bc6e
commit
364aa667ad
5 changed files with 274 additions and 59 deletions
|
@ -66,6 +66,10 @@ class ItemsModel: ObservableObject {
|
|||
private var navigationTimeoutTask: Task<Void, Never>? = nil
|
||||
private var loadChatTask: Task<Void, Never>? = nil
|
||||
|
||||
var lastItemsLoaded: Bool {
|
||||
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
|
||||
}
|
||||
|
||||
init() {
|
||||
publisher
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||
|
|
|
@ -60,6 +60,8 @@ func apiLoadMessages(
|
|||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
|
||||
PreloadState.shared.clear()
|
||||
}
|
||||
case let .before(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
|
@ -104,19 +106,22 @@ func apiLoadMessages(
|
|||
}
|
||||
}
|
||||
case .around:
|
||||
let newSplits: [Int64]
|
||||
var newSplits: [Int64]
|
||||
if openAroundItemId == nil {
|
||||
newItems.append(contentsOf: oldItems)
|
||||
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||
} else {
|
||||
newSplits = []
|
||||
}
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
||||
let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
|
||||
//indexToInsertAroundTest()
|
||||
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
|
||||
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
let orderedSplits = newSplits
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = [chat.chatItems.last!.id] + newSplits
|
||||
chatState.splits = orderedSplits
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
|
@ -130,14 +135,16 @@ func apiLoadMessages(
|
|||
// no need to set it, count will be wrong
|
||||
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
}
|
||||
PreloadState.shared.clear()
|
||||
}
|
||||
case .last:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
removeDuplicates(&newItems, chat)
|
||||
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
|
||||
newItems.append(contentsOf: chat.chatItems)
|
||||
let items = newItems
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = items.reversed()
|
||||
chatState.splits = newSplits
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatState.unreadAfterNewestLoaded = 0
|
||||
}
|
||||
|
@ -234,10 +241,14 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
|||
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
||||
// Currently, it should always load from split range
|
||||
let loadingFromSplitRange = indexInSplitRanges != nil
|
||||
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
||||
Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
||||
let topSplits: [Int64]
|
||||
var splitsToMerge: [Int64]
|
||||
if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
||||
splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
||||
topSplits = Array(splits[0 ..< indexInSplitRanges + 1])
|
||||
} else {
|
||||
[]
|
||||
splitsToMerge = []
|
||||
topSplits = []
|
||||
}
|
||||
newItems.removeAll(where: { new in
|
||||
let duplicate = newIds.contains(new.id)
|
||||
|
@ -257,8 +268,8 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
|||
})
|
||||
var newSplits: [Int64] = []
|
||||
if firstItemIdBelowAllSplits != nil {
|
||||
// no splits anymore, all were merged with bottom items
|
||||
newSplits = []
|
||||
// no splits below anymore, all were merged with bottom items
|
||||
newSplits = topSplits
|
||||
} else {
|
||||
if !splitsToRemove.isEmpty {
|
||||
var new = splits
|
||||
|
@ -320,6 +331,28 @@ private func removeDuplicatesAndUpperSplits(
|
|||
return newSplits
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndUnusedSplits(
|
||||
_ newItems: inout [ChatItem],
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64]
|
||||
) async -> [Int64] {
|
||||
if splits.isEmpty {
|
||||
removeDuplicates(&newItems, chat)
|
||||
return splits
|
||||
}
|
||||
|
||||
var newSplits = splits
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll(where: {
|
||||
let duplicate = newIds.contains($0.id)
|
||||
if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) {
|
||||
newSplits.remove(at: firstIndex)
|
||||
}
|
||||
return duplicate
|
||||
})
|
||||
return newSplits
|
||||
}
|
||||
|
||||
// ids, number of unread items
|
||||
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
||||
var unreadInLoaded = 0
|
||||
|
@ -340,3 +373,139 @@ private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
|
|||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll { newIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private typealias SameTimeItem = (index: Int, item: ChatItem)
|
||||
|
||||
// return (item index, split index)
|
||||
private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set<Int64>) -> (Int, Int) {
|
||||
guard to.count > 0, let lastNew = lastNew else { return (0, 0) }
|
||||
// group sorting: item_ts, item_id
|
||||
// everything else: created_at, item_id
|
||||
let compareByTimeTs = chatType == .group
|
||||
// in case several items have the same time as another item in the `to` array
|
||||
var sameTime: [SameTimeItem] = []
|
||||
|
||||
// trying to find new split index for item looks difficult but allows to not use one more loop.
|
||||
// The idea is to memorize how many splits were till any index (map number of splits until index)
|
||||
// and use resulting itemIndex to decide new split index position.
|
||||
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
|
||||
var splitsTillIndex: [Int] = []
|
||||
var splitsPerPrevIndex = 0
|
||||
|
||||
for i in 0 ..< to.count {
|
||||
let item = to[i]
|
||||
|
||||
splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex
|
||||
splitsTillIndex.append(splitsPerPrevIndex)
|
||||
|
||||
let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt)
|
||||
if itemIsNewer || i + 1 == to.count {
|
||||
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
|
||||
sameTime.append((i, item))
|
||||
}
|
||||
// time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them
|
||||
let itemIndex: Int
|
||||
if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) {
|
||||
itemIndex = first.index
|
||||
} else if sameTime.count == 1 {
|
||||
itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1
|
||||
} else {
|
||||
itemIndex = itemIsNewer ? i : i + 1
|
||||
}
|
||||
let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)]
|
||||
let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)]
|
||||
return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex)
|
||||
}
|
||||
|
||||
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
|
||||
sameTime.append(SameTimeItem(index: i, item: item))
|
||||
} else {
|
||||
sameTime = []
|
||||
}
|
||||
}
|
||||
// shouldn't be here
|
||||
return (to.count, splits.count)
|
||||
}
|
||||
|
||||
private func indexToInsertAroundTest() {
|
||||
func assert(_ one: (Int, Int), _ two: (Int, Int)) {
|
||||
if one != two {
|
||||
logger.debug("\(String(describing: one)) != \(String(describing: two))")
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
|
||||
let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")]
|
||||
let items1 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1))
|
||||
|
||||
let items2 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1))
|
||||
|
||||
let items3 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1))
|
||||
|
||||
let items4 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0))
|
||||
|
||||
let items5 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1))
|
||||
|
||||
let items6 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0))
|
||||
|
||||
let items7 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0))
|
||||
|
||||
let items8 = [
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0))
|
||||
|
||||
let items9 = [
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0))
|
||||
|
||||
let items10 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0))
|
||||
|
||||
let items11: [ChatItem] = []
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0))
|
||||
}
|
||||
|
|
|
@ -9,25 +9,23 @@
|
|||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) {
|
||||
if ItemsModel.shared.chatState.totalAfter == 0 {
|
||||
return
|
||||
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = true
|
||||
loadingBottomItems.wrappedValue = true
|
||||
}
|
||||
loadingMoreItems.wrappedValue = true
|
||||
loadingBottomItems.wrappedValue = true
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 500_000000)
|
||||
if ChatModel.shared.chatId != chat.chatInfo.id {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
|
||||
try? await Task.sleep(nanoseconds: 500_000000)
|
||||
if ChatModel.shared.chatId != chat.chatInfo.id {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
loadingBottomItems.wrappedValue = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
loadingBottomItems.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,6 +34,12 @@ class PreloadState {
|
|||
var prevFirstVisible: Int64 = Int64.min
|
||||
var prevItemsCount: Int = 0
|
||||
var preloading: Bool = false
|
||||
|
||||
func clear() {
|
||||
prevFirstVisible = Int64.min
|
||||
prevItemsCount = 0
|
||||
preloading = false
|
||||
}
|
||||
}
|
||||
|
||||
func preloadIfNeeded(
|
||||
|
@ -43,26 +47,41 @@ func preloadIfNeeded(
|
|||
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||
_ mergedItems: BoxedValue<MergedItems>,
|
||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
|
||||
loadLastItems: @escaping () async -> Void
|
||||
) {
|
||||
let state = PreloadState.shared
|
||||
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
||||
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
||||
!state.preloading,
|
||||
listState.totalItemsCount > 0
|
||||
else {
|
||||
return
|
||||
}
|
||||
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
|
||||
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
|
||||
state.preloading = true
|
||||
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
||||
Task {
|
||||
defer {
|
||||
state.preloading = false
|
||||
if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
|
||||
state.preloading = true
|
||||
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
||||
Task {
|
||||
defer { state.preloading = false }
|
||||
var triedToLoad = true
|
||||
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
|
||||
triedToLoad = await loadItems(false, pagination)
|
||||
return triedToLoad
|
||||
}
|
||||
if triedToLoad {
|
||||
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
|
||||
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
|
||||
}
|
||||
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
|
||||
// split will be merged with last items and position of scroll will change unexpectedly.
|
||||
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
|
||||
await loadLastItems()
|
||||
}
|
||||
}
|
||||
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
|
||||
await loadItems(false, pagination)
|
||||
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
|
||||
state.preloading = true
|
||||
Task {
|
||||
defer { state.preloading = false }
|
||||
await loadLastItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -105,6 +124,7 @@ async {
|
|||
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
|
||||
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
|
||||
ignoreLoadingRequests.wrappedValue = loadFromItemId
|
||||
return false
|
||||
}
|
||||
return triedToLoad
|
||||
}
|
||||
|
|
|
@ -91,7 +91,11 @@ struct ChatView: View {
|
|||
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
|
||||
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
|
||||
}
|
||||
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel)
|
||||
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
|
||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
|
@ -262,7 +266,6 @@ struct ChatView: View {
|
|||
|
||||
// this may already being loading because of changed chat id (see .onChange(of: chat.id)
|
||||
if !loadingBottomItems {
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
allowLoadMoreItems = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
|
@ -584,7 +587,6 @@ struct ChatView: View {
|
|||
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||
}
|
||||
.onChange(of: chat.id) { _ in
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
allowLoadMoreItems = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
|
@ -629,7 +631,6 @@ struct ChatView: View {
|
|||
if let unreadIndex {
|
||||
scrollView.scrollToItem(unreadIndex)
|
||||
}
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
allowLoadMoreItems = true
|
||||
}
|
||||
|
@ -647,10 +648,8 @@ struct ChatView: View {
|
|||
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
// scroll to the top unread item
|
||||
scrollView.scrollToItem(index)
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
} else {
|
||||
scrollView.scrollToBottom()
|
||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -731,6 +730,7 @@ struct ChatView: View {
|
|||
let theme: AppTheme
|
||||
let scrollView: EndlessScrollView<MergedItem>
|
||||
let chat: Chat
|
||||
@Binding var loadingMoreItems: Bool
|
||||
@Binding var loadingTopItems: Bool
|
||||
@Binding var requestedTopScroll: Bool
|
||||
@Binding var loadingBottomItems: Bool
|
||||
|
@ -738,6 +738,7 @@ struct ChatView: View {
|
|||
@Binding var animatedScrollingInProgress: Bool
|
||||
let listState: EndlessScrollView<MergedItem>.ListState
|
||||
@ObservedObject var model: FloatingButtonModel
|
||||
let reloadItems: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
|
@ -795,7 +796,7 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if loadingBottomItems {
|
||||
if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
|
||||
requestedTopScroll = false
|
||||
requestedBottomScroll = true
|
||||
} else {
|
||||
|
@ -815,7 +816,7 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
.onChange(of: loadingBottomItems) { loading in
|
||||
if !loading && requestedBottomScroll {
|
||||
if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
|
||||
requestedBottomScroll = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
@ -824,15 +825,25 @@ struct ChatView: View {
|
|||
}
|
||||
|
||||
private func scrollToTopUnread() {
|
||||
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
animatedScrollingInProgress = true
|
||||
// scroll to the top unread item
|
||||
Task {
|
||||
Task {
|
||||
if !ItemsModel.shared.chatState.splits.isEmpty {
|
||||
await MainActor.run { loadingMoreItems = true }
|
||||
await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
|
||||
await MainActor.run { reloadItems() }
|
||||
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
await MainActor.run { animatedScrollingInProgress = true }
|
||||
await scrollView.scrollToItemAnimated(index)
|
||||
await MainActor.run { animatedScrollingInProgress = false }
|
||||
}
|
||||
await MainActor.run { loadingMoreItems = false }
|
||||
} else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
await MainActor.run { animatedScrollingInProgress = true }
|
||||
// scroll to the top unread item
|
||||
await scrollView.scrollToItemAnimated(index)
|
||||
await MainActor.run { animatedScrollingInProgress = false }
|
||||
} else {
|
||||
logger.debug("No more unread items, total: \(listState.items.count)")
|
||||
}
|
||||
} else {
|
||||
logger.debug("No more unread items, total: \(listState.items.count)")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1147,6 +1158,11 @@ struct ChatView: View {
|
|||
} else {
|
||||
await loadChatItems(chat, pagination)
|
||||
}
|
||||
},
|
||||
loadLastItems: {
|
||||
if !loadingMoreItems {
|
||||
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -1247,18 +1263,11 @@ struct ChatView: View {
|
|||
nil
|
||||
}
|
||||
let showAvatar = shouldShowAvatar(item, listItem.nextItem)
|
||||
let itemSeparation: ItemSeparation
|
||||
let single = switch merged {
|
||||
case .single: true
|
||||
default: false
|
||||
}
|
||||
if single || revealed {
|
||||
let prev = listItem.prevItem
|
||||
itemSeparation = getItemSeparation(item, prev)
|
||||
let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem
|
||||
} else {
|
||||
itemSeparation = getItemSeparation(item, nil)
|
||||
}
|
||||
let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
|
||||
return VStack(spacing: 0) {
|
||||
if let last {
|
||||
DateSeparator(date: last.meta.itemTs).padding(8)
|
||||
|
|
|
@ -171,6 +171,9 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
visibleItems.last?.index ?? 0
|
||||
}
|
||||
|
||||
/// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
|
||||
var itemsCanCoverScreen: Bool = false
|
||||
|
||||
/// Whether there is a non-animated scroll to item in progress or not
|
||||
var isScrolling: Bool = false
|
||||
/// Whether there is an animated scroll to item in progress or not
|
||||
|
@ -284,7 +287,8 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
|
||||
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
|
||||
if !Thread.isMainThread {
|
||||
fatalError("Use main thread to update items")
|
||||
logger.error("Use main thread to update items")
|
||||
return
|
||||
}
|
||||
if bounds.height == 0 {
|
||||
self.listState.items = items
|
||||
|
@ -302,6 +306,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
if items.isEmpty {
|
||||
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
|
||||
listState.visibleItems = []
|
||||
listState.itemsCanCoverScreen = false
|
||||
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = 0
|
||||
listState.firstVisibleItemOffset = -insetTop
|
||||
|
@ -322,6 +327,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
|
||||
var oldVisible = listState.visibleItems
|
||||
var newVisible: [VisibleItem] = []
|
||||
var visibleItemsHeight: CGFloat = 0
|
||||
let offsetsDiff = contentOffsetY - prevProcessedOffset
|
||||
|
||||
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
|
||||
|
@ -389,6 +395,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
nextOffsetY = vis.view.frame.origin.y
|
||||
} else {
|
||||
let vis: VisibleItem
|
||||
|
@ -406,6 +413,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
}
|
||||
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
|
||||
break
|
||||
|
@ -435,6 +443,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
}
|
||||
offset += vis.view.frame.height
|
||||
newVisible.insert(vis, at: 0)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
if offset >= contentOffsetY + bounds.height {
|
||||
break
|
||||
}
|
||||
|
@ -450,11 +459,15 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
prevProcessedOffset = contentOffsetY
|
||||
|
||||
listState.visibleItems = newVisible
|
||||
listState.items = items
|
||||
// bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
|
||||
// For visible items to preserve offset after adding more items having such height is enough
|
||||
listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
|
||||
|
||||
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
|
||||
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
|
||||
// updating the items with the last step in order to call listener with fully updated state
|
||||
listState.items = items
|
||||
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
|
||||
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
|
||||
|
|
Loading…
Add table
Reference in a new issue