mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
ios: scrolling improvements (#5746)
* 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 navigationTimeoutTask: Task<Void, Never>? = nil
|
||||||
private var loadChatTask: Task<Void, Never>? = nil
|
private var loadChatTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
|
var lastItemsLoaded: Bool {
|
||||||
|
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
publisher
|
publisher
|
||||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
|
|
@ -60,6 +60,8 @@ func apiLoadMessages(
|
||||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||||
chatState.unreadAfter = navInfo.afterUnread
|
chatState.unreadAfter = navInfo.afterUnread
|
||||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||||
|
|
||||||
|
PreloadState.shared.clear()
|
||||||
}
|
}
|
||||||
case let .before(paginationChatItemId, _):
|
case let .before(paginationChatItemId, _):
|
||||||
newItems.append(contentsOf: oldItems)
|
newItems.append(contentsOf: oldItems)
|
||||||
|
@ -104,19 +106,22 @@ func apiLoadMessages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .around:
|
case .around:
|
||||||
let newSplits: [Int64]
|
var newSplits: [Int64]
|
||||||
if openAroundItemId == nil {
|
if openAroundItemId == nil {
|
||||||
newItems.append(contentsOf: oldItems)
|
newItems.append(contentsOf: oldItems)
|
||||||
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||||
} else {
|
} else {
|
||||||
newSplits = []
|
newSplits = []
|
||||||
}
|
}
|
||||||
// currently, items will always be added on top, which is index 0
|
let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
|
||||||
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
//indexToInsertAroundTest()
|
||||||
|
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
|
||||||
|
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
|
||||||
let newReversed: [ChatItem] = newItems.reversed()
|
let newReversed: [ChatItem] = newItems.reversed()
|
||||||
|
let orderedSplits = newSplits
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
ItemsModel.shared.reversedChatItems = newReversed
|
ItemsModel.shared.reversedChatItems = newReversed
|
||||||
chatState.splits = [chat.chatItems.last!.id] + newSplits
|
chatState.splits = orderedSplits
|
||||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||||
chatState.totalAfter = navInfo.afterTotal
|
chatState.totalAfter = navInfo.afterTotal
|
||||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||||
|
@ -130,14 +135,16 @@ func apiLoadMessages(
|
||||||
// no need to set it, count will be wrong
|
// no need to set it, count will be wrong
|
||||||
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||||
}
|
}
|
||||||
|
PreloadState.shared.clear()
|
||||||
}
|
}
|
||||||
case .last:
|
case .last:
|
||||||
newItems.append(contentsOf: oldItems)
|
newItems.append(contentsOf: oldItems)
|
||||||
removeDuplicates(&newItems, chat)
|
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
|
||||||
newItems.append(contentsOf: chat.chatItems)
|
newItems.append(contentsOf: chat.chatItems)
|
||||||
let items = newItems
|
let items = newItems
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
ItemsModel.shared.reversedChatItems = items.reversed()
|
ItemsModel.shared.reversedChatItems = items.reversed()
|
||||||
|
chatState.splits = newSplits
|
||||||
chatModel.updateChatInfo(chat.chatInfo)
|
chatModel.updateChatInfo(chat.chatInfo)
|
||||||
chatState.unreadAfterNewestLoaded = 0
|
chatState.unreadAfterNewestLoaded = 0
|
||||||
}
|
}
|
||||||
|
@ -234,10 +241,14 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||||
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
||||||
// Currently, it should always load from split range
|
// Currently, it should always load from split range
|
||||||
let loadingFromSplitRange = indexInSplitRanges != nil
|
let loadingFromSplitRange = indexInSplitRanges != nil
|
||||||
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
let topSplits: [Int64]
|
||||||
Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
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 {
|
} else {
|
||||||
[]
|
splitsToMerge = []
|
||||||
|
topSplits = []
|
||||||
}
|
}
|
||||||
newItems.removeAll(where: { new in
|
newItems.removeAll(where: { new in
|
||||||
let duplicate = newIds.contains(new.id)
|
let duplicate = newIds.contains(new.id)
|
||||||
|
@ -257,8 +268,8 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||||
})
|
})
|
||||||
var newSplits: [Int64] = []
|
var newSplits: [Int64] = []
|
||||||
if firstItemIdBelowAllSplits != nil {
|
if firstItemIdBelowAllSplits != nil {
|
||||||
// no splits anymore, all were merged with bottom items
|
// no splits below anymore, all were merged with bottom items
|
||||||
newSplits = []
|
newSplits = topSplits
|
||||||
} else {
|
} else {
|
||||||
if !splitsToRemove.isEmpty {
|
if !splitsToRemove.isEmpty {
|
||||||
var new = splits
|
var new = splits
|
||||||
|
@ -320,6 +331,28 @@ private func removeDuplicatesAndUpperSplits(
|
||||||
return newSplits
|
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
|
// ids, number of unread items
|
||||||
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
||||||
var unreadInLoaded = 0
|
var unreadInLoaded = 0
|
||||||
|
@ -340,3 +373,139 @@ private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
|
||||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||||
newItems.removeAll { newIds.contains($0.id) }
|
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 SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) {
|
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
|
||||||
if ItemsModel.shared.chatState.totalAfter == 0 {
|
await MainActor.run {
|
||||||
return
|
loadingMoreItems.wrappedValue = true
|
||||||
|
loadingBottomItems.wrappedValue = true
|
||||||
}
|
}
|
||||||
loadingMoreItems.wrappedValue = true
|
try? await Task.sleep(nanoseconds: 500_000000)
|
||||||
loadingBottomItems.wrappedValue = true
|
if ChatModel.shared.chatId != chat.chatInfo.id {
|
||||||
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)
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
loadingMoreItems.wrappedValue = false
|
loadingMoreItems.wrappedValue = false
|
||||||
loadingBottomItems.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 prevFirstVisible: Int64 = Int64.min
|
||||||
var prevItemsCount: Int = 0
|
var prevItemsCount: Int = 0
|
||||||
var preloading: Bool = false
|
var preloading: Bool = false
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
prevFirstVisible = Int64.min
|
||||||
|
prevItemsCount = 0
|
||||||
|
preloading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preloadIfNeeded(
|
func preloadIfNeeded(
|
||||||
|
@ -43,26 +47,41 @@ func preloadIfNeeded(
|
||||||
_ ignoreLoadingRequests: Binding<Int64?>,
|
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||||
_ mergedItems: BoxedValue<MergedItems>,
|
_ mergedItems: BoxedValue<MergedItems>,
|
||||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
|
||||||
|
loadLastItems: @escaping () async -> Void
|
||||||
) {
|
) {
|
||||||
let state = PreloadState.shared
|
let state = PreloadState.shared
|
||||||
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
||||||
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
|
||||||
!state.preloading,
|
!state.preloading,
|
||||||
listState.totalItemsCount > 0
|
listState.totalItemsCount > 0
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
|
if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
|
||||||
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
|
state.preloading = true
|
||||||
state.preloading = true
|
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
||||||
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
Task {
|
||||||
Task {
|
defer { state.preloading = false }
|
||||||
defer {
|
var triedToLoad = true
|
||||||
state.preloading = false
|
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
|
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
|
||||||
await loadItems(false, pagination)
|
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))
|
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 {
|
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
|
||||||
ignoreLoadingRequests.wrappedValue = loadFromItemId
|
ignoreLoadingRequests.wrappedValue = loadFromItemId
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return triedToLoad
|
return triedToLoad
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,7 +91,11 @@ struct ChatView: View {
|
||||||
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
|
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
|
||||||
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
|
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()
|
connectingText()
|
||||||
if selectedChatItems == nil {
|
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)
|
// this may already being loading because of changed chat id (see .onChange(of: chat.id)
|
||||||
if !loadingBottomItems {
|
if !loadingBottomItems {
|
||||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
|
||||||
allowLoadMoreItems = false
|
allowLoadMoreItems = false
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
allowLoadMoreItems = true
|
allowLoadMoreItems = true
|
||||||
|
@ -584,7 +587,6 @@ struct ChatView: View {
|
||||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||||
}
|
}
|
||||||
.onChange(of: chat.id) { _ in
|
.onChange(of: chat.id) { _ in
|
||||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
|
||||||
allowLoadMoreItems = false
|
allowLoadMoreItems = false
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
allowLoadMoreItems = true
|
allowLoadMoreItems = true
|
||||||
|
@ -629,7 +631,6 @@ struct ChatView: View {
|
||||||
if let unreadIndex {
|
if let unreadIndex {
|
||||||
scrollView.scrollToItem(unreadIndex)
|
scrollView.scrollToItem(unreadIndex)
|
||||||
}
|
}
|
||||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
allowLoadMoreItems = true
|
allowLoadMoreItems = true
|
||||||
}
|
}
|
||||||
|
@ -647,10 +648,8 @@ struct ChatView: View {
|
||||||
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
|
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
|
||||||
// scroll to the top unread item
|
// scroll to the top unread item
|
||||||
scrollView.scrollToItem(index)
|
scrollView.scrollToItem(index)
|
||||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
|
||||||
} else {
|
} else {
|
||||||
scrollView.scrollToBottom()
|
scrollView.scrollToBottom()
|
||||||
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -731,6 +730,7 @@ struct ChatView: View {
|
||||||
let theme: AppTheme
|
let theme: AppTheme
|
||||||
let scrollView: EndlessScrollView<MergedItem>
|
let scrollView: EndlessScrollView<MergedItem>
|
||||||
let chat: Chat
|
let chat: Chat
|
||||||
|
@Binding var loadingMoreItems: Bool
|
||||||
@Binding var loadingTopItems: Bool
|
@Binding var loadingTopItems: Bool
|
||||||
@Binding var requestedTopScroll: Bool
|
@Binding var requestedTopScroll: Bool
|
||||||
@Binding var loadingBottomItems: Bool
|
@Binding var loadingBottomItems: Bool
|
||||||
|
@ -738,6 +738,7 @@ struct ChatView: View {
|
||||||
@Binding var animatedScrollingInProgress: Bool
|
@Binding var animatedScrollingInProgress: Bool
|
||||||
let listState: EndlessScrollView<MergedItem>.ListState
|
let listState: EndlessScrollView<MergedItem>.ListState
|
||||||
@ObservedObject var model: FloatingButtonModel
|
@ObservedObject var model: FloatingButtonModel
|
||||||
|
let reloadItems: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
|
@ -795,7 +796,7 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if loadingBottomItems {
|
if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
|
||||||
requestedTopScroll = false
|
requestedTopScroll = false
|
||||||
requestedBottomScroll = true
|
requestedBottomScroll = true
|
||||||
} else {
|
} else {
|
||||||
|
@ -815,7 +816,7 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: loadingBottomItems) { loading in
|
.onChange(of: loadingBottomItems) { loading in
|
||||||
if !loading && requestedBottomScroll {
|
if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
|
||||||
requestedBottomScroll = false
|
requestedBottomScroll = false
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
@ -824,15 +825,25 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToTopUnread() {
|
private func scrollToTopUnread() {
|
||||||
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
|
Task {
|
||||||
animatedScrollingInProgress = true
|
if !ItemsModel.shared.chatState.splits.isEmpty {
|
||||||
// scroll to the top unread item
|
await MainActor.run { loadingMoreItems = true }
|
||||||
Task {
|
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 scrollView.scrollToItemAnimated(index)
|
||||||
await MainActor.run { animatedScrollingInProgress = false }
|
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 {
|
} else {
|
||||||
await loadChatItems(chat, pagination)
|
await loadChatItems(chat, pagination)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
loadLastItems: {
|
||||||
|
if !loadingMoreItems {
|
||||||
|
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1247,18 +1263,11 @@ struct ChatView: View {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
let showAvatar = shouldShowAvatar(item, listItem.nextItem)
|
let showAvatar = shouldShowAvatar(item, listItem.nextItem)
|
||||||
let itemSeparation: ItemSeparation
|
|
||||||
let single = switch merged {
|
let single = switch merged {
|
||||||
case .single: true
|
case .single: true
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
if single || revealed {
|
let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
|
||||||
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)
|
|
||||||
}
|
|
||||||
return VStack(spacing: 0) {
|
return VStack(spacing: 0) {
|
||||||
if let last {
|
if let last {
|
||||||
DateSeparator(date: last.meta.itemTs).padding(8)
|
DateSeparator(date: last.meta.itemTs).padding(8)
|
||||||
|
|
|
@ -171,6 +171,9 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
visibleItems.last?.index ?? 0
|
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
|
/// Whether there is a non-animated scroll to item in progress or not
|
||||||
var isScrolling: Bool = false
|
var isScrolling: Bool = false
|
||||||
/// Whether there is an animated scroll to item in progress or not
|
/// 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) {
|
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
|
||||||
if !Thread.isMainThread {
|
if !Thread.isMainThread {
|
||||||
fatalError("Use main thread to update items")
|
logger.error("Use main thread to update items")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if bounds.height == 0 {
|
if bounds.height == 0 {
|
||||||
self.listState.items = items
|
self.listState.items = items
|
||||||
|
@ -302,6 +306,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
|
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
|
||||||
listState.visibleItems = []
|
listState.visibleItems = []
|
||||||
|
listState.itemsCanCoverScreen = false
|
||||||
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||||
listState.firstVisibleItemIndex = 0
|
listState.firstVisibleItemIndex = 0
|
||||||
listState.firstVisibleItemOffset = -insetTop
|
listState.firstVisibleItemOffset = -insetTop
|
||||||
|
@ -322,6 +327,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
|
|
||||||
var oldVisible = listState.visibleItems
|
var oldVisible = listState.visibleItems
|
||||||
var newVisible: [VisibleItem] = []
|
var newVisible: [VisibleItem] = []
|
||||||
|
var visibleItemsHeight: CGFloat = 0
|
||||||
let offsetsDiff = contentOffsetY - prevProcessedOffset
|
let offsetsDiff = contentOffsetY - prevProcessedOffset
|
||||||
|
|
||||||
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
|
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)
|
addSubview(vis.view)
|
||||||
}
|
}
|
||||||
newVisible.append(vis)
|
newVisible.append(vis)
|
||||||
|
visibleItemsHeight += vis.view.frame.height
|
||||||
nextOffsetY = vis.view.frame.origin.y
|
nextOffsetY = vis.view.frame.origin.y
|
||||||
} else {
|
} else {
|
||||||
let vis: VisibleItem
|
let vis: VisibleItem
|
||||||
|
@ -406,6 +413,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
addSubview(vis.view)
|
addSubview(vis.view)
|
||||||
}
|
}
|
||||||
newVisible.append(vis)
|
newVisible.append(vis)
|
||||||
|
visibleItemsHeight += vis.view.frame.height
|
||||||
}
|
}
|
||||||
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
|
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
|
||||||
break
|
break
|
||||||
|
@ -435,6 +443,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
}
|
}
|
||||||
offset += vis.view.frame.height
|
offset += vis.view.frame.height
|
||||||
newVisible.insert(vis, at: 0)
|
newVisible.insert(vis, at: 0)
|
||||||
|
visibleItemsHeight += vis.view.frame.height
|
||||||
if offset >= contentOffsetY + bounds.height {
|
if offset >= contentOffsetY + bounds.height {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -450,11 +459,15 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
prevProcessedOffset = contentOffsetY
|
prevProcessedOffset = contentOffsetY
|
||||||
|
|
||||||
listState.visibleItems = newVisible
|
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.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||||
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
|
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
|
||||||
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
|
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)
|
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
|
||||||
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
|
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue