ios: enhancements to floating buttons (#5644)

* ios: enhancements to floating buttons

* nearBottom

* timeout

* changes

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko 2025-02-20 17:39:04 +07:00 committed by GitHub
parent dc980ae88f
commit 676583d3c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 95 additions and 36 deletions

View file

@ -78,7 +78,7 @@ class ItemsModel: ObservableObject {
loadChatTask?.cancel() loadChatTask?.cancel()
navigationTimeoutTask = Task { navigationTimeoutTask = Task {
do { do {
try await Task.sleep(nanoseconds: 100_000000) try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run { await MainActor.run {
ChatModel.shared.chatId = chatId ChatModel.shared.chatId = chatId
willNavigate() willNavigate()
@ -949,7 +949,7 @@ final class ChatModel: ObservableObject {
memberIds.insert(m.groupMemberId) memberIds.insert(m.groupMemberId)
} }
} else { } else {
logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count)") logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
} }
} }
return (prevMember, memberIds.count) return (prevMember, memberIds.count)

View file

@ -9,13 +9,14 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, _ chat: Chat) { func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) {
if ItemsModel.shared.chatState.totalAfter == 0 { if ItemsModel.shared.chatState.totalAfter == 0 {
return return
} }
loadingMoreItems.wrappedValue = true loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
Task { Task {
try? await Task.sleep(nanoseconds: 1500_000000) try? await Task.sleep(nanoseconds: 500_000000)
if ChatModel.shared.chatId != chat.chatInfo.id { if ChatModel.shared.chatId != chat.chatInfo.id {
await MainActor.run { await MainActor.run {
loadingMoreItems.wrappedValue = false loadingMoreItems.wrappedValue = false
@ -25,6 +26,7 @@ func loadLastItems(_ loadingMoreItems: Binding<Bool>, _ chat: Chat) {
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) 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
} }
} }
} }

View file

@ -33,6 +33,10 @@ struct ChatView: View {
@State private var customUserProfile: Profile? @State private var customUserProfile: Profile?
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var loadingMoreItems = false @State private var loadingMoreItems = false
@State private var loadingTopItems = false
@State private var requestedTopScroll = false
@State private var loadingBottomItems = false
@State private var requestedBottomScroll = false
@State private var searchMode = false @State private var searchMode = false
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
@ -49,6 +53,7 @@ struct ChatView: View {
@State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false
@State private var allowLoadMoreItems: Bool = false @State private var allowLoadMoreItems: Bool = false
@State private var ignoreLoadingRequests: Int64? = nil @State private var ignoreLoadingRequests: Int64? = nil
@State private var animatedScrollingInProgress: Bool = false
@State private var updateMergedItemsTask: Task<Void, Never>? = nil @State private var updateMergedItemsTask: Task<Void, Never>? = nil
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
@ -88,7 +93,7 @@ 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, loadingMoreItems: $loadingMoreItems, listState: scrollView.listState, model: floatingButtonModel) FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@ -424,7 +429,9 @@ struct ChatView: View {
index = mergedItems.boxedValue.indexInParentItems[itemId] index = mergedItems.boxedValue.indexInParentItems[itemId]
} }
if let index { if let index {
await MainActor.run { animatedScrollingInProgress = true }
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
await MainActor.run { animatedScrollingInProgress = false }
} }
} catch { } catch {
logger.error("Error scrolling to item: \(error)") logger.error("Error scrolling to item: \(error)")
@ -551,7 +558,7 @@ 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, chat) 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
@ -571,10 +578,10 @@ 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, chat) loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
} else { } else {
scrollView.scrollToBottom() scrollView.scrollToBottom()
loadLastItems($loadingMoreItems, chat) loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
} }
} }
} }
@ -620,7 +627,7 @@ struct ChatView: View {
if let unreadIndex { if let unreadIndex {
scrollView.scrollToItem(unreadIndex) scrollView.scrollToItem(unreadIndex)
} }
loadLastItems($loadingMoreItems, chat) loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true allowLoadMoreItems = true
} }
@ -659,7 +666,7 @@ struct ChatView: View {
} }
// set floating button indication mode // set floating button indication mode
let nearBottom = listState.firstVisibleItemIndex < 4 let nearBottom = listState.firstVisibleItemIndex < 1
if nearBottom != self.isNearBottom { if nearBottom != self.isNearBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom self?.isNearBottom = nearBottom
@ -702,7 +709,11 @@ 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 requestedTopScroll: Bool
@Binding var loadingBottomItems: Bool
@Binding var requestedBottomScroll: Bool
@Binding var animatedScrollingInProgress: Bool
let listState: EndlessScrollView<MergedItem>.ListState let listState: EndlessScrollView<MergedItem>.ListState
@ObservedObject var model: FloatingButtonModel @ObservedObject var model: FloatingButtonModel
@ -717,8 +728,8 @@ struct ChatView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
VStack { VStack {
if model.unreadAbove > 0 { if model.unreadAbove > 0 && !animatedScrollingInProgress {
if loadingMoreItems { if loadingTopItems && requestedTopScroll {
circleButton { ProgressView() } circleButton { ProgressView() }
} else { } else {
circleButton { circleButton {
@ -727,11 +738,11 @@ struct ChatView: View {
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
} }
.onTapGesture { .onTapGesture {
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { if loadingTopItems {
// scroll to the top unread item requestedTopScroll = true
Task { await scrollView.scrollToItemAnimated(index) } requestedBottomScroll = false
} else { } else {
logger.debug("No more unread items, total: \(listState.items.count)") scrollToTopUnread()
} }
} }
.contextMenu { .contextMenu {
@ -746,37 +757,71 @@ struct ChatView: View {
} }
} }
Spacer() Spacer()
if model.unreadBelow > 0 { if listState.firstVisibleItemIndex != 0 && !animatedScrollingInProgress {
if loadingMoreItems { if loadingBottomItems && requestedBottomScroll {
circleButton { ProgressView() } circleButton { ProgressView() }
} else { } else {
circleButton { circleButton {
unreadCountText(model.unreadBelow) Group {
.font(.callout) if model.unreadBelow > 0 {
.foregroundColor(theme.colors.primary) unreadCountText(model.unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
} else {
Image(systemName: "chevron.down").foregroundColor(theme.colors.primary)
}
}
} }
.onTapGesture { .onTapGesture {
scrollView.scrollToBottomAnimated() if loadingBottomItems {
requestedTopScroll = false
requestedBottomScroll = true
} else {
scrollToBottom()
}
} }
} }
} else if !model.isNearBottom {
if loadingMoreItems {
circleButton { ProgressView() }
} else {
circleButton {
Image(systemName: "chevron.down").foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollView.scrollToBottomAnimated() }
}
} }
} }
.disabled(loadingMoreItems)
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
.onChange(of: loadingTopItems) { loading in
if !loading && requestedTopScroll {
requestedTopScroll = false
scrollToTopUnread()
}
}
.onChange(of: loadingBottomItems) { loading in
if !loading && requestedBottomScroll {
requestedBottomScroll = false
scrollToBottom()
}
}
.onDisappear(perform: model.resetDate) .onDisappear(perform: model.resetDate)
} }
private func scrollToTopUnread() {
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
animatedScrollingInProgress = true
// scroll to the top unread item
Task {
await scrollView.scrollToItemAnimated(index)
await MainActor.run { animatedScrollingInProgress = false }
}
} else {
logger.debug("No more unread items, total: \(listState.items.count)")
}
}
private func scrollToBottom() {
animatedScrollingInProgress = true
Task {
await scrollView.scrollToItemAnimated(0, top: false)
await MainActor.run { animatedScrollingInProgress = false }
}
}
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View { private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack { ZStack {
Circle() Circle()
@ -1010,10 +1055,20 @@ struct ChatView: View {
if loadingMoreItems { return false } if loadingMoreItems { return false }
await MainActor.run { await MainActor.run {
loadingMoreItems = true loadingMoreItems = true
if case .before = pagination {
loadingTopItems = true
} else if case .after = pagination {
loadingBottomItems = true
}
} }
let triedToLoad = await loadChatItemsUnchecked(chat, pagination) let triedToLoad = await loadChatItemsUnchecked(chat, pagination)
await MainActor.run { await MainActor.run {
loadingMoreItems = false loadingMoreItems = false
if case .before = pagination {
loadingTopItems = false
} else if case .after = pagination {
loadingBottomItems = false
}
} }
return triedToLoad return triedToLoad
} }

View file

@ -136,8 +136,11 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
} }
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
if height.wrappedValue != newHeight { if height.wrappedValue != newHeight ||
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) // when both heights equal to minHeight, we must update $height, even if it's the same, because only this way
// the swift ui wrapper will redisplay this view with updated height
newHeight == NativeTextEditor.minHeight {
DispatchQueue.main.async { self.height.wrappedValue = self.newHeight }
} }
return CGSizeMake(0, newHeight) return CGSizeMake(0, newHeight)
} }

View file

@ -74,7 +74,6 @@
savedToolIdentifier = "" savedToolIdentifier = ""
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2"> launchAutomaticallySubstyle = "2">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">