mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
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:
parent
dc980ae88f
commit
676583d3c3
5 changed files with 95 additions and 36 deletions
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue