forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChatScrollPositionDetector.swift
More file actions
233 lines (208 loc) · 8.01 KB
/
Copy pathChatScrollPositionDetector.swift
File metadata and controls
233 lines (208 loc) · 8.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import AppKit
import SwiftUI
/// An AppKit-owned snapshot of the transcript viewport. Sending this through
/// the scroll detector keeps scroll/layout measurement out of the SwiftUI
/// message stack, which otherwise risks an AttributeGraph feedback loop.
struct ChatScrollPosition: Equatable {
let isAtBottom: Bool
let scrollTop: CGFloat
let viewportHeight: CGFloat
let documentHeight: CGFloat
}
/// Detects scroll position changes by observing the underlying NSScrollView.
struct ScrollPositionDetector: NSViewRepresentable {
let onScrollPositionChange: (ChatScrollPosition) -> Void
var onScrollViewResolved: ((NSScrollView) -> Void)? = nil
func makeNSView(context: Context) -> NSView {
let view = ScrollPositionDetectorHostView()
view.onHierarchyChange = { [weak coordinator = context.coordinator, weak view] in
guard let coordinator, let view else { return }
coordinator.scheduleAttachment(for: view)
}
context.coordinator.scheduleAttachment(for: view)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.onScrollViewResolved = onScrollViewResolved
// SwiftUI can replace the underlying NSScrollView while a long stack is
// being laid out. Re-check the hierarchy on every update so scrolling
// does not remain wired to a detached clip view.
context.coordinator.setupScrollObserver(for: nsView)
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.stop()
}
func makeCoordinator() -> Coordinator {
Coordinator(
onScrollPositionChange: onScrollPositionChange,
onScrollViewResolved: onScrollViewResolved
)
}
final class Coordinator: NSObject, @unchecked Sendable {
/// `RunLoop.Mode.common` is not guaranteed to include AppKit's event
/// tracking mode. Trackpad and wheel gestures run there, so an update
/// queued only in common modes can leave `deliveryScheduled` latched until
/// the gesture returns to the default run loop.
private static let eventTrackingRunLoopMode = RunLoop.Mode("NSEventTrackingRunLoopMode")
let onScrollPositionChange: (ChatScrollPosition) -> Void
var onScrollViewResolved: ((NSScrollView) -> Void)?
private var scrollView: NSScrollView?
private weak var observedClipView: NSClipView?
private weak var observedDocumentView: NSView?
private var boundsObservation: NSObjectProtocol?
private var documentFrameObservation: NSObjectProtocol?
private var lastReportedPosition: ChatScrollPosition?
private var pendingPosition: ChatScrollPosition?
private var deliveryScheduled = false
private var deliveryGeneration = 0
init(
onScrollPositionChange: @escaping (ChatScrollPosition) -> Void,
onScrollViewResolved: ((NSScrollView) -> Void)?
) {
self.onScrollPositionChange = onScrollPositionChange
self.onScrollViewResolved = onScrollViewResolved
}
func scheduleAttachment(for view: NSView) {
for delay in [0.0, 0.05, 0.2] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak view] in
guard let self, let view else { return }
self.setupScrollObserver(for: view)
}
}
}
func setupScrollObserver(for view: NSView) {
MainActor.assumeIsolated {
guard let targetScrollView = Self.enclosingScrollView(for: view) else {
stop()
return
}
let clipView = targetScrollView.contentView
let documentView = targetScrollView.documentView
if scrollView === targetScrollView,
observedClipView === clipView,
observedDocumentView === documentView
{
checkScrollPosition()
return
}
stop()
scrollView = targetScrollView
observedClipView = clipView
observedDocumentView = documentView
onScrollViewResolved?(targetScrollView)
clipView.postsBoundsChangedNotifications = true
boundsObservation = NotificationCenter.default.addObserver(
forName: NSView.boundsDidChangeNotification,
object: clipView,
queue: .main
) { [weak self] _ in
self?.checkScrollPosition()
}
if let documentView {
documentView.postsFrameChangedNotifications = true
documentFrameObservation = NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: documentView,
queue: .main
) { [weak self] _ in
self?.checkScrollPosition()
}
}
checkScrollPosition()
}
}
func stop() {
MainActor.assumeIsolated {
deliveryGeneration &+= 1
deliveryScheduled = false
pendingPosition = nil
lastReportedPosition = nil
if let boundsObservation {
NotificationCenter.default.removeObserver(boundsObservation)
}
boundsObservation = nil
if let documentFrameObservation {
NotificationCenter.default.removeObserver(documentFrameObservation)
}
documentFrameObservation = nil
scrollView = nil
observedClipView = nil
observedDocumentView = nil
}
}
@MainActor
private static func enclosingScrollView(for view: NSView) -> NSScrollView? {
var current: NSView? = view
while let candidate = current {
if let scrollView = candidate as? NSScrollView {
return scrollView
}
current = candidate.superview
}
return nil
}
func checkScrollPosition() {
MainActor.assumeIsolated {
guard let scrollView, let documentView = scrollView.documentView else { return }
let clipBounds = scrollView.contentView.bounds
let documentHeight = documentView.frame.height
let scrollTop = ChatScrollLiveEdge.topBasedScrollOffset(
clipOriginY: clipBounds.origin.y,
viewportHeight: clipBounds.height,
documentHeight: documentHeight,
isDocumentFlipped: documentView.isFlipped
)
let visibleMaxY = scrollTop + clipBounds.height
let position = ChatScrollPosition(
isAtBottom: ChatScrollLiveEdge.isAtBottom(
visibleMaxY: visibleMaxY,
documentHeight: documentHeight),
scrollTop: scrollTop,
viewportHeight: clipBounds.height,
documentHeight: documentHeight
)
let hasPendingDelivery = pendingPosition != nil
guard hasPendingDelivery || shouldReport(position) else { return }
pendingPosition = position
guard !deliveryScheduled else { return }
deliveryScheduled = true
let generation = deliveryGeneration
RunLoop.main.perform(
inModes: [.common, .default, Self.eventTrackingRunLoopMode]
) { [weak self] in
MainActor.assumeIsolated {
guard let self, self.deliveryGeneration == generation else { return }
self.deliveryScheduled = false
guard let pendingPosition = self.pendingPosition else { return }
self.pendingPosition = nil
self.lastReportedPosition = pendingPosition
self.onScrollPositionChange(pendingPosition)
}
}
}
}
private func shouldReport(_ position: ChatScrollPosition) -> Bool {
guard let lastReportedPosition else { return true }
return position.isAtBottom != lastReportedPosition.isAtBottom
|| abs(position.scrollTop - lastReportedPosition.scrollTop) >= 4
|| abs(position.viewportHeight - lastReportedPosition.viewportHeight) >= 4
|| abs(position.documentHeight - lastReportedPosition.documentHeight) >= 4
}
deinit {
MainActor.assumeIsolated {
stop()
}
}
}
}
private final class ScrollPositionDetectorHostView: NSView {
var onHierarchyChange: (() -> Void)?
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onHierarchyChange?()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onHierarchyChange?()
}
}