mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 12:19:54 +00:00
ios: loading progress moved to chat list (#5639)
* ios: loading progress moved to chat list * place * changes * large spinner, smaller timeout --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
parent
ec519afb3f
commit
dc980ae88f
7 changed files with 196 additions and 122 deletions
|
@ -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<Void, Never>? = nil
|
||||
private var loadChatTask: Task<Void, Never>? = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<MergedItems> = BoxedValue(MergedItems(items: [], splits: [], indexInParentItems: [:]))
|
||||
@State var mergedItems: BoxedValue<MergedItems> = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState))
|
||||
@State var revealedItems: Set<Int64> = 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<MergedItem> = 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<some View> {
|
||||
Button {
|
||||
archivingReports = [cItem.id]
|
||||
|
@ -1940,7 +1959,7 @@ struct ChatView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func reportButton(_ ci: ChatItem) -> Button<some View> {
|
||||
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: {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -115,6 +115,8 @@ class EndlessScrollView<ScrollItem>: 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<ScrollItem>: 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<ScrollItem>: 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<ScrollItem>: 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<ScrollItem>: 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<ScrollItem>: 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<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate {
|
||||
snapToContent()
|
||||
snapToContent(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -582,7 +641,7 @@ class EndlessScrollView<ScrollItem>: 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<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
|||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setContentOffset(newValue, animated: false)
|
||||
self.snapToContent()
|
||||
self.snapToContent(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue