From ee1a7c44157eb6970ec2111fc1ac4d67a44a8238 Mon Sep 17 00:00:00 2001 From: Mike Robbins Date: Tue, 14 Oct 2025 16:58:55 -0400 Subject: [PATCH] ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification --- .../Views/Messages/ChannelMessageList.swift | 46 +++++++++++++++---- .../Views/Messages/UserMessageList.swift | 41 +++++++++++++---- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 4134d4ec..5102099f 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -23,7 +23,8 @@ struct ChannelMessageList: View { @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 @State private var messageToHighlight: Int64 = 0 @FetchRequest private var allPrivateMessages: FetchedResults - + @State private var scrollToBottomWorkItem: DispatchWorkItem? + init(myInfo: MyInfoEntity, channel: ChannelEntity) { self.myInfo = myInfo self.channel = channel @@ -50,15 +51,30 @@ struct ChannelMessageList: View { for unreadMessage in allPrivateMessages.filter({ !$0.read }) { unreadMessage.read = true } - try context.save() - Logger.data.info("📖 [App] All unread messages marked as read.") + + if context.hasChanges { + try context.save() + Logger.data.info("📖 [App] All unread messages marked as read.") + } + appState.unreadChannelMessages = myInfo.unreadMessages context.refresh(myInfo, mergeChanges: true) } catch { Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)") } } - + + func debouncedScrollToBottom(scrollView: ScrollViewProxy, lastMessageId: Int64?, delay: TimeInterval = 0.1) { + scrollToBottomWorkItem?.cancel() + + let scrollTarget: AnyHashable = lastMessageId != nil ? lastMessageId : "bottomAnchor" + let work = DispatchWorkItem { + scrollView.scrollTo(scrollTarget, anchor: .bottom) + } + scrollToBottomWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + var body: some View { // Cast allPrivateMessages to an array for easier indexing and ForEach. let messages: [MessageEntity] = Array(allPrivateMessages) @@ -71,6 +87,8 @@ struct ChannelMessageList: View { return dict }() + let lastMessageId: Int64? = messages.last?.messageId + ScrollViewReader { scrollView in ScrollView { LazyVStack { @@ -98,11 +116,10 @@ struct ChannelMessageList: View { // So, run it in the main queue after everything saves and stabilizes DispatchQueue.main.async { markMessagesAsRead() - scrollView.scrollTo("bottomAnchor", anchor: .bottom) } + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1) } } - } Color.clear .frame(height: 1) @@ -113,12 +130,21 @@ struct ChannelMessageList: View { .defaultScrollAnchorTopAlignment() .defaultScrollAnchorBottomSizeChanges() .scrollDismissesKeyboard(.immediately) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + // Keyboard is about to appear: keyboard show animation hasn't quite started yet. + // Schedule an immediate scroll to the bottom message by its messageId, in order to force LazyVStack to render that cell if it isn't rendered already + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + // Keyboard is fully visible. + // Scroll after the keyboard is fully showing, with a short delay to allow things to settle (TextMessageField height update, for example) + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1) + } .onChange(of: messageFieldFocused) { if messageFieldFocused { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } + // macOS doesn't have keyboard show animation, but we still want to scroll to the bottom. + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0) + } } TextMessageField( destination: .channel(channel), diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 79cc4b38..4e9c6881 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -22,6 +22,7 @@ struct UserMessageList: View { @State private var redrawTapbacksTrigger = UUID() @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 @FetchRequest private var allPrivateMessages: FetchedResults + @State private var scrollToBottomWorkItem: DispatchWorkItem? init(user: UserEntity) { self.user = user @@ -41,8 +42,11 @@ struct UserMessageList: View { for unreadMessage in allPrivateMessages.filter({ !$0.read }) { unreadMessage.read = true } - try context.save() - Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).") + + if context.hasChanges { + try context.save() + Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).") + } if let connectedPeripheralNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context), @@ -56,6 +60,17 @@ struct UserMessageList: View { } } + func debouncedScrollToBottom(scrollView: ScrollViewProxy, lastMessageId: Int64?, delay: TimeInterval = 0.1) { + scrollToBottomWorkItem?.cancel() + + let scrollTarget: AnyHashable = lastMessageId != nil ? lastMessageId : "bottomAnchor" + let work = DispatchWorkItem { + scrollView.scrollTo(scrollTarget, anchor: .bottom) + } + scrollToBottomWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + var body: some View { // Cast user.messageList to an array for easier indexing and ForEach. let messages: [MessageEntity] = Array(allPrivateMessages) @@ -68,6 +83,8 @@ struct UserMessageList: View { return dict }() + let lastMessageId: Int64? = messages.last?.messageId + VStack { ScrollViewReader { scrollView in ScrollView { @@ -96,11 +113,10 @@ struct UserMessageList: View { // So, run it in the main queue after everything saves and stabilizes DispatchQueue.main.async { markMessagesAsRead() - scrollView.scrollTo("bottomAnchor", anchor: .bottom) } + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1) } } - } // Invisible spacer to detect reaching bottom Color.clear @@ -112,12 +128,21 @@ struct UserMessageList: View { .defaultScrollAnchorTopAlignment() .defaultScrollAnchorBottomSizeChanges() .scrollDismissesKeyboard(.immediately) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + // Keyboard is about to appear: keyboard show animation hasn't quite started yet. + // Schedule an immediate scroll to the bottom message by its messageId, in order to force LazyVStack to render that cell if it isn't rendered already + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + // Keyboard is fully visible. + // Scroll after the keyboard is fully showing, with a short delay to allow things to settle (TextMessageField height update, for example) + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1) + } .onChange(of: messageFieldFocused) { if messageFieldFocused { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } + // macOS doesn't have keyboard show animation, but we still want to scroll to the bottom. + debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0) + } } } TextMessageField(