Private
Public Access
1
0

osx: better scroll view management

This commit is contained in:
2025-09-12 15:58:34 -07:00
parent 6261351598
commit bc51bf03a1
4 changed files with 71 additions and 42 deletions

View File

@@ -14,7 +14,7 @@ struct ConversationView: View
@Binding var entryModel: MessageEntryView.ViewModel @Binding var entryModel: MessageEntryView.ViewModel
var body: some View { var body: some View {
VStack { VStack(spacing: 0.0) {
TranscriptView(model: $transcriptModel) TranscriptView(model: $transcriptModel)
MessageEntryView(viewModel: $entryModel) MessageEntryView(viewModel: $entryModel)
} }

Binary file not shown.

View File

@@ -10,7 +10,7 @@ import SwiftUI
extension TranscriptView.ViewModel extension TranscriptView.ViewModel
{ {
internal func rebuildDisplayItems(animated: Bool = false) { internal func rebuildDisplayItems(animated: Bool = false, completion: () -> Void = {}) {
var displayItems: [DisplayItem] = [] var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil var lastSender: Display.Sender? = nil
@@ -53,6 +53,7 @@ extension TranscriptView.ViewModel
let animation: Animation? = animated ? .default : nil let animation: Animation? = animated ? .default : nil
withAnimation(animation) { withAnimation(animation) {
self.displayItems = displayItems self.displayItems = displayItems
completion()
} }
} }
} }

View File

@@ -12,25 +12,57 @@ struct TranscriptView: View
@Binding var model: ViewModel @Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient @Environment(\.xpcClient) private var xpcClient
init(model: Binding<ViewModel>) {
self._model = model
}
var body: some View { var body: some View {
ScrollView { ScrollViewReader { proxy in
LazyVStack(spacing: 6.0) { ScrollView {
ForEach($model.displayItems.reversed()) { item in // For resetting scroll position to the "bottom"
displayItemView(item.wrappedValue) EmptyView()
.id(item.id) .id(ViewID.bottomAnchor)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition( LazyVStack(spacing: 6.0) {
.push(from: .top) ForEach($model.displayItems.reversed()) { item in
.combined(with: .opacity) displayItemView(item.wrappedValue)
) .id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .top)
.combined(with: .opacity)
)
}
}
.padding()
}
// Flip vertically so newest messages are at the bottom.
.scaleEffect(CGSize(width: 1.0, height: -1.0))
// Watch for xpc events
.task { await watchForMessageListChanges() }
// On conversation change, reload displayed messages and mark as read.
.onChange(of: model.displayedConversation) { oldValue, newValue in
Task {
guard oldValue != newValue else { return }
// Reload NOW
await model.reloadMessages(animated: false) {
// Once that's done, scroll to the "bottom" (actually top)
proxy.scrollTo(ViewID.bottomAnchor, anchor: .top)
}
}
Task.detached {
// Mark as read on server, and trigger a sync.
await model.markAsRead()
await model.triggerSync()
} }
} }
.padding()
} }
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.id(model.displayedConversation?.id)
.task { await watchForMessageListChanges() }
} }
private func watchForMessageListChanges() async { private func watchForMessageListChanges() async {
@@ -79,9 +111,16 @@ struct TranscriptView: View
} }
} }
} }
// MARK: - Types // MARK: - Types
enum ViewID: String
{
case bottomAnchor
}
// MARK: - View Model
@Observable @Observable
class ViewModel class ViewModel
{ {
@@ -94,7 +133,6 @@ struct TranscriptView: View
init(messages: [Display.Message] = []) { init(messages: [Display.Message] = []) {
self.messages = messages self.messages = messages
observeDisplayedConversation()
rebuildDisplayItems() rebuildDisplayItems()
} }
@@ -106,7 +144,7 @@ struct TranscriptView: View
needsReload = .yes(animated) needsReload = .yes(animated)
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
await reloadMessages() await reloadIfNeeded()
} }
} }
@@ -115,22 +153,6 @@ struct TranscriptView: View
setNeedsReload(animated: false) setNeedsReload(animated: false)
} }
private func observeDisplayedConversation() {
withObservationTracking {
_ = displayedConversation
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
await triggerSync()
setNeedsReload(animated: false)
observeDisplayedConversation()
}
}
}
func markAsRead() async { func markAsRead() async {
guard let displayedConversation else { return } guard let displayedConversation else { return }
@@ -150,11 +172,15 @@ struct TranscriptView: View
print("Error triggering sync: \(error)") print("Error triggering sync: \(error)")
} }
} }
private func reloadMessages() async { func reloadIfNeeded(completion: () -> Void = {}) async {
guard case .yes(let animated) = needsReload else { return } guard case .yes(let animated) = needsReload else { return }
needsReload = .no needsReload = .no
await reloadMessages(animated: animated, completion: completion)
}
func reloadMessages(animated: Bool, completion: () -> Void) async {
guard let displayedConversation else { return } guard let displayedConversation else { return }
do { do {
@@ -167,8 +193,10 @@ struct TranscriptView: View
// Only animate for incoming messages. // Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1) let shouldAnimate = (newIds.count == 1)
self.messages = clientMessages await MainActor.run {
self.rebuildDisplayItems(animated: animated && shouldAnimate) self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion)
}
} catch { } catch {
print("Message fetch error: \(error)") print("Message fetch error: \(error)")
} }