diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 52500595ca..56fe727e5f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -61,7 +61,10 @@ class ItemsModel: ObservableObject { // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @Published var isLoading = false - @Published var showLoadingProgress = false + @Published var showLoadingProgress: ChatId? = nil + + private var navigationTimeoutTask: Task? = nil + private var loadChatTask: Task? = nil init() { publisher @@ -71,32 +74,26 @@ class ItemsModel: ObservableObject { } func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { - let navigationTimeout = Task { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + navigationTimeoutTask = Task { do { - try await Task.sleep(nanoseconds: 250_000000) + try await Task.sleep(nanoseconds: 100_000000) await MainActor.run { - willNavigate() ChatModel.shared.chatId = chatId + willNavigate() } } catch {} } - let progressTimeout = Task { - do { - try await Task.sleep(nanoseconds: 1500_000000) - await MainActor.run { showLoadingProgress = true } - } catch {} - } - Task { + loadChatTask = Task { await MainActor.run { self.isLoading = true } - // try? await Task.sleep(nanoseconds: 5000_000000) +// try? await Task.sleep(nanoseconds: 1000_000000) await loadChat(chatId: chatId) - navigationTimeout.cancel() - progressTimeout.cancel() - await MainActor.run { - self.isLoading = false - self.showLoadingProgress = false - willNavigate() -// ChatModel.shared.chatId = id + if !Task.isCancelled { + await MainActor.run { + self.isLoading = false + self.showLoadingProgress = nil + } } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51d2a8444d..0ef2a87aa7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -335,7 +335,7 @@ func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") } func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id) + await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) } func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 5920b8eef7..4253a4f1e4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -32,7 +32,7 @@ func apiLoadMessages( // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) { + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled { return } @@ -50,7 +50,6 @@ func apiLoadMessages( chatModel.chatItemStatuses.removeAll() ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() chatModel.updateChatInfo(chat.chatInfo) - chatModel.chatId = chat.chatInfo.id chatState.splits = newSplits if !chat.chatItems.isEmpty { chatState.unreadAfterItemId = chat.chatItems.last!.id diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 396493d6c5..a8d19733b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -16,7 +16,7 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var im = ItemsModel.shared - @State var mergedItems: BoxedValue = BoxedValue(MergedItems(items: [], splits: [], indexInParentItems: [:])) + @State var mergedItems: BoxedValue = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState)) @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @@ -55,7 +55,7 @@ struct ChatView: View { private let useItemsUpdateTask = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) - + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial var body: some View { @@ -120,6 +120,9 @@ struct ChatView: View { ) } } + if im.showLoadingProgress == chat.id { + ProgressView().scaleEffect(2) + } } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { @@ -201,6 +204,16 @@ struct ChatView: View { selectedChatItems = nil revealedItems = Set() initChatView() + if im.isLoading { + Task { + try? await Task.sleep(nanoseconds: 500_000000) + await MainActor.run { + if im.isLoading { + im.showLoadingProgress = chat.id + } + } + } + } } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false @@ -214,10 +227,10 @@ struct ChatView: View { scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() - Task { - if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { - await scrollView.scrollToItem(unreadIndex, animated: false) - } + if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + scrollView.scrollToItem(unreadIndex) + } else { + scrollView.scrollToBottom() } } else { dismiss() @@ -290,7 +303,6 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - let isLoading = im.isLoading && im.showLoadingProgress if selectedChatItems != nil { Button { withAnimation { @@ -313,23 +325,19 @@ struct ChatView: View { } } Menu { - if !isLoading { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") - } - .disabled(!contact.ready || !contact.active) + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) + .disabled(!contact.ready || !contact.active) } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } } } case let .group(groupInfo): @@ -351,14 +359,10 @@ struct ChatView: View { } } Menu { - if !isLoading { - searchButton() - ToggleNtfsButton(chat: chat) - } + searchButton() + ToggleNtfsButton(chat: chat) } label: { Image(systemName: "ellipsis") - .tint(isLoading ? Color.clear : nil) - .overlay { if isLoading { ProgressView() } } } } case .local: @@ -370,7 +374,7 @@ struct ChatView: View { } } } - + private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -420,14 +424,14 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { - await scrollView.scrollToItem(min(ItemsModel.shared.reversedChatItems.count - 1, index), animated: true) + await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) } } catch { logger.error("Error scrolling to item: \(error)") } } } - + private func searchToolbar() -> some View { HStack(spacing: 12) { HStack(spacing: 4) { @@ -458,7 +462,7 @@ struct ChatView: View { .padding(.horizontal) .padding(.vertical, 8) } - + private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } @@ -514,25 +518,13 @@ struct ChatView: View { .id(ci.id) // Required to trigger `onAppear` on iOS15 } .onAppear { - Task { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) - let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) - let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } - await MainActor.run { - // this helps to speed up initial process of setting scroll position and reduce time needed - // to layout items on screen - if let unreadIndex, let unreadItemId { - scrollView.setScrollPosition(unreadIndex, unreadItemId) - } - scrollView.updateItems(mergedItems.boxedValue.items) - } - if let unreadIndex { - await scrollView.scrollToItem(unreadIndex, animated: false) - } + if !im.isLoading { + updateWithInitiallyLoadedItems() } - loadLastItems($loadingMoreItems, chat) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - allowLoadMoreItems = true + } + .onChange(of: im.isLoading) { loading in + if !loading { + updateWithInitiallyLoadedItems() } } .onChange(of: im.reversedChatItems) { items in @@ -565,18 +557,24 @@ struct ChatView: View { allowLoadMoreItems = true } } - .opacity(ItemsModel.shared.isLoading ? 0 : 1) .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in Task { await loadChat(chat: chat, search: s) - if s.isEmpty { - await scrollView.scrollToItem(0, animated: false, top: false) - loadLastItems($loadingMoreItems, chat) + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + await MainActor.run { + scrollView.updateItems(mergedItems.boxedValue.items) + } + if !s.isEmpty { + scrollView.scrollToBottom() } else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item - await scrollView.scrollToItem(index, animated: false) + scrollView.scrollToItem(index) + loadLastItems($loadingMoreItems, chat) + } else { + scrollView.scrollToBottom() + loadLastItems($loadingMoreItems, chat) } } } @@ -584,7 +582,7 @@ struct ChatView: View { if added { im.itemAdded = false if scrollView.listState.firstVisibleItemIndex < 2 { - scrollView.scrollToBottom() + scrollView.scrollToBottomAnimated() } else { scrollView.scroll(by: 34) } @@ -607,6 +605,27 @@ struct ChatView: View { } } + private func updateWithInitiallyLoadedItems() { + if mergedItems.boxedValue.items.isEmpty { + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState) + } + let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) + let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } + // this helps to speed up initial process of setting scroll position and reduce time needed + // to layout items on screen + if let unreadIndex, let unreadItemId { + scrollView.setScrollPosition(unreadIndex, unreadItemId) + } + scrollView.updateItems(mergedItems.boxedValue.items) + if let unreadIndex { + scrollView.scrollToItem(unreadIndex) + } + loadLastItems($loadingMoreItems, chat) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + class FloatingButtonModel: ObservableObject { @Published var unreadAbove: Int = 0 @Published var unreadBelow: Int = 0 @@ -638,7 +657,7 @@ struct ChatView: View { it.unreadBelow = unreadBelow it.date = date } - + // set floating button indication mode let nearBottom = listState.firstVisibleItemIndex < 4 if nearBottom != self.isNearBottom { @@ -646,7 +665,7 @@ struct ChatView: View { self?.isNearBottom = nearBottom } } - + // hide Date indicator after 1 second of no scrolling hideDateWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -710,7 +729,7 @@ struct ChatView: View { .onTapGesture { if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { // scroll to the top unread item - Task { await scrollView.scrollToItem(index, animated: true) } + Task { await scrollView.scrollToItemAnimated(index) } } else { logger.debug("No more unread items, total: \(listState.items.count)") } @@ -737,7 +756,7 @@ struct ChatView: View { .foregroundColor(theme.colors.primary) } .onTapGesture { - scrollView.scrollToBottom() + scrollView.scrollToBottomAnimated() } } } else if !model.isNearBottom { @@ -747,7 +766,7 @@ struct ChatView: View { circleButton { Image(systemName: "chevron.down").foregroundColor(theme.colors.primary) } - .onTapGesture { scrollView.scrollToBottom() } + .onTapGesture { scrollView.scrollToBottomAnimated() } } } } @@ -949,7 +968,7 @@ struct ChatView: View { ) } } - + func forwardAction(_ items: [Int64]) -> UIAlertAction { UIAlertAction( title: NSLocalizedString("Forward messages", comment: "alert action"), @@ -1078,7 +1097,7 @@ struct ChatView: View { private func reveal(_ yes: Bool) -> Void { merged.revealItems(yes, $revealedItems) } - + func getItemSeparation(_ chatItem: ChatItem, _ prevItem: ChatItem?) -> ItemSeparation { guard let prevItem else { return ItemSeparation(timestamp: true, largeGap: true, date: nil) @@ -1189,7 +1208,7 @@ struct ChatView: View { let im = ItemsModel.shared var unreadItems: [ChatItem.ID] = [] var unreadMentions: Int = 0 - + for i in range { let ci = im.reversedChatItems[i] if ci.isRcvNew { @@ -1199,10 +1218,10 @@ struct ChatView: View { } } } - + return (unreadItems, unreadMentions) } - + private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { markReadTask = Task { do { @@ -1215,7 +1234,7 @@ struct ChatView: View { } } } - + @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1892,7 +1911,7 @@ struct ChatView: View { ) } } - + private func archiveReportButton(_ cItem: ChatItem) -> Button { Button { archivingReports = [cItem.id] @@ -1940,7 +1959,7 @@ struct ChatView: View { ) } } - + private func reportButton(_ ci: ChatItem) -> Button { Button(role: .destructive) { var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in @@ -1954,9 +1973,9 @@ struct ChatView: View { } } } - + buttons.append(.cancel()) - + actionSheet = SomeActionSheet( actionSheet: ActionSheet( title: Text("Report reason?"), @@ -1971,7 +1990,7 @@ struct ChatView: View { ) } } - + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -2044,7 +2063,7 @@ struct ChatView: View { } } } - + @ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View { if !r.userReacted || r.totalReacted > 1 { Button { showChatInfoSheet = true } label: { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 0d79566d8b..676d9c43d2 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -20,12 +20,12 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void - private let minHeight: CGFloat = 37 + static let minHeight: CGFloat = 39 private let defaultHeight: CGFloat = { let field = CustomUITextField(parent: nil, height: Binding.constant(0)) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) - return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), 360).rounded(.down) }() func makeUIView(context: Context) -> UITextView { @@ -81,7 +81,7 @@ struct NativeTextEditor: UIViewRepresentable { // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size let newHeight = field.text == "" ? defaultHeight - : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down) if field.frame.size.height != newHeight { field.frame.size = CGSizeMake(field.frame.size.width, newHeight) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9554772721..64cfe3d260 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -33,7 +33,7 @@ struct SendMessageView: View { @Namespace var namespace @Binding var keyboardVisible: Bool var sendButtonColor = Color.accentColor - @State private var teHeight: CGFloat = 42 + @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 7509a1fb6a..c19a1f00a2 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -115,6 +115,8 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu /// Adds content padding to bottom var insetBottom: CGFloat = 100 + var scrollToItemIndexDelayed: Int? = nil + /// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight] let scrollBarView: UIScrollView = UIScrollView(frame: .zero) @@ -260,6 +262,10 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } oldScreenHeight = newScreenHeight adaptItems(listState.items, false) + if let index = scrollToItemIndexDelayed { + scrollToItem(index) + scrollToItemIndexDelayed = nil + } } private func setup() { @@ -284,7 +290,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu return } adaptItems(items, forceReloadVisible) - snapToContent() + snapToContent(animated: false) } /// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes @@ -454,21 +460,70 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset } - func scrollToItem(_ index: Int, animated: Bool, top: Bool = true) async { + func scrollToItem(_ index: Int, top: Bool = true) { if index >= listState.items.count || listState.isScrolling { return } + if bounds.height == 0 { + scrollToItemIndexDelayed = index + return + } listState.isScrolling = true defer { listState.isScrolling = false } - if !animated { - // just a faster way to set top item as requested index - listState.firstVisibleItemIndex = index - listState.firstVisibleItemId = listState.items[index].id - listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom - scrollBarView.flashScrollIndicators() - adaptItems(listState.items, false) + + // just a faster way to set top item as requested index + listState.firstVisibleItemIndex = index + listState.firstVisibleItemId = listState.items[index].id + listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom + scrollBarView.flashScrollIndicators() + adaptItems(listState.items, false) + + var adjustedOffset = self.contentOffset.y + var i = 0 + + var upPrev = index > listState.firstVisibleItemIndex + //let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) + + var stepSlowdownMultiplier: CGFloat = 1 + while true { + let up = index > listState.firstVisibleItemIndex + if upPrev != up { + stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 + upPrev = up + } + + // these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index + let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex + let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier + + let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step * stepSlowdownMultiplier + adjustedOffset += offsetToScroll + if let item = listState.visibleItems.first(where: { $0.index == index }) { + let y = if top { + min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) + } else { + max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) + } + setContentOffset(CGPointMake(contentOffset.x, y), animated: false) + scrollBarView.flashScrollIndicators() + break + } + contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + i += 1 + } + estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) + } + + func scrollToItemAnimated(_ index: Int, top: Bool = true) async { + if index >= listState.items.count || listState.isScrolling { + return + } + listState.isScrolling = true + defer { + listState.isScrolling = false } var adjustedOffset = self.contentOffset.y var i = 0 @@ -497,28 +552,32 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) } - setContentOffset(CGPointMake(contentOffset.x, y), animated: animated) + setContentOffset(CGPointMake(contentOffset.x, y), animated: true) scrollBarView.flashScrollIndicators() break } contentOffset = CGPointMake(contentOffset.x, adjustedOffset) - if animated { - // skipping unneded relayout if this offset is already processed - if prevProcessedOffset - contentOffset.y != 0 { - adaptItems(listState.items, false) - snapToContent(animated: false) - } - // let UI time to update to see the animated position change - await MainActor.run {} + + // skipping unneded relayout if this offset is already processed + if prevProcessedOffset - contentOffset.y != 0 { + adaptItems(listState.items, false) + snapToContent(animated: false) } + // let UI time to update to see the animated position change + await MainActor.run {} + i += 1 } estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) } - func scrollToBottom(animated: Bool = true) { + func scrollToBottom() { + scrollToItem(0, top: false) + } + + func scrollToBottomAnimated() { Task { - await scrollToItem(0, animated: animated, top: false) + await scrollToItemAnimated(0, top: false) } } @@ -528,12 +587,12 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { if !listState.items.isEmpty { - scrollToBottom() + scrollToBottomAnimated() } return false } - private func snapToContent(animated: Bool = true) { + private func snapToContent(animated: Bool) { let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0 if topY < estimatedContentHeight.topOffsetY - topBlankSpace { setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated) @@ -567,7 +626,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - snapToContent() + snapToContent(animated: true) } } @@ -582,7 +641,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { DispatchQueue.main.async { self.setContentOffset(newValue, animated: false) - self.snapToContent() + self.snapToContent(animated: true) } } } else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y { @@ -591,7 +650,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } else { DispatchQueue.main.async { self.setContentOffset(newValue, animated: false) - self.snapToContent() + self.snapToContent(animated: true) } } }