Skip to content

Commit e27d085

Browse files
committed
fix: replace usesAutomaticRowHeights with deferred self-sizing
NSHostingView inside NSTableView with usesAutomaticRowHeights enters an infinite layout loop: layout() flushes SwiftUI transactions, which invalidate constraints, which re-enter layout. AppKit detects this and crashes with NSGenericException. Replace the entire auto-sizing mechanism with SelfSizingHostingView: - sizingOptions = [] prevents the hosting view from participating in Auto Layout constraint solving. - invalidateIntrinsicContentSize() is overridden to NOT call super (which would re-enter the constraint system) but instead schedule a coalesced async height update via DispatchQueue.main.async. - measureHeight() uses a temporary NSHostingController.sizeThatFits() to properly measure content at the cell width, including text wrapping. - heightOfRow returns cached heights (default 60px). noteHeightOfRows is called with animation duration 0 to avoid visible row resizing. Also removes the old measurementHostingView and handleTableResize.
1 parent 984bcdf commit e27d085

1 file changed

Lines changed: 65 additions & 49 deletions

File tree

Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class TimelineViewController: NSViewController {
8787

8888
let timeline: LiveTimeline
8989
var timelineItems: [TimelineItem]
90+
var rowHeights: [Int: CGFloat] = [:]
91+
private var pendingHeightUpdate = false
9092

9193
init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) {
9294
self.coordinator = coordinator
@@ -104,30 +106,29 @@ class TimelineViewController: NSViewController {
104106
tableView.allowsColumnSelection = false
105107
tableView.selectionHighlightStyle = .none
106108

107-
tableView.rowHeight = -1
108-
tableView.usesAutomaticRowHeights = true
109-
110-
oldWidth = tableView.frame.width
109+
tableView.usesAutomaticRowHeights = false
110+
tableView.rowHeight = 60
111111

112112
dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in
113113
guard let self else { return NSView() }
114114

115115
let item = timelineItems[row]
116116
let view = TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator)
117117

118-
let hostView: NSHostingView<TimelineItemRowView>
118+
let hostView: SelfSizingHostingView<TimelineItemRowView>
119119
if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self)
120-
as? NSHostingView<TimelineItemRowView>
120+
as? SelfSizingHostingView<TimelineItemRowView>
121121
{
122122
recycledView.rootView = view
123123
hostView = recycledView
124124
} else {
125-
hostView = NSHostingView<TimelineItemRowView>(rootView: view)
125+
hostView = SelfSizingHostingView<TimelineItemRowView>(rootView: view)
126126
hostView.identifier = item.rowInfo.reuseIdentifier
127+
hostView.sizingOptions = []
127128
hostView.autoresizingMask = [.width, .height]
128-
hostView.sizingOptions = [.preferredContentSize]
129-
hostView.setContentHuggingPriority(.required, for: .vertical)
130129
}
130+
hostView.row = row
131+
hostView.controller = self
131132

132133
return hostView
133134
}
@@ -142,13 +143,6 @@ class TimelineViewController: NSViewController {
142143

143144
// Subscribe to view resize notifications
144145
scrollView.contentView.postsBoundsChangedNotifications = true
145-
NotificationCenter.default.addObserver(
146-
self,
147-
selector: #selector(handleTableResize),
148-
name: NSView.frameDidChangeNotification,
149-
object: scrollView.contentView
150-
)
151-
152146
NotificationCenter.default.addObserver(
153147
self,
154148
selector: #selector(viewDidScroll(_:)),
@@ -159,21 +153,6 @@ class TimelineViewController: NSViewController {
159153
listenForFocusTimelineItem()
160154
}
161155

162-
@objc func handleTableResize(_ notification: Notification) {
163-
if oldWidth != tableView.frame.width {
164-
oldWidth = tableView.frame.width
165-
166-
NSAnimationContext.runAnimationGroup { context in
167-
context.duration = 0
168-
context.allowsImplicitAnimation = false
169-
170-
let visibleRect = tableView.visibleRect
171-
let visibleRows = tableView.rows(in: visibleRect)
172-
tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound))
173-
}
174-
}
175-
}
176-
177156
var timelineFetchTask: Task<Void, Never>?
178157

179158
@objc func viewDidScroll(_ notification: Notification) {
@@ -252,18 +231,10 @@ class TimelineViewController: NSViewController {
252231
// Re-measure visible rows after hosting views settle
253232
DispatchQueue.main.async { [weak self] in
254233
guard let self else { return }
255-
let visibleRows = tableView.rows(in: tableView.visibleRect)
256-
tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound..<visibleRows.upperBound))
234+
self.rowHeights.removeAll()
235+
self.scheduleHeightUpdate()
257236
}
258237
}
259-
260-
// values used to track width changes
261-
var oldWidth: CGFloat?
262-
let measurementHostingView = {
263-
let hostView = NSHostingController(rootView: AnyView(EmptyView()))
264-
hostView.sizingOptions = [.preferredContentSize]
265-
return hostView
266-
}()
267238
}
268239

269240
extension TimelineViewController: NSTableViewDelegate {
@@ -276,16 +247,36 @@ extension TimelineViewController: NSTableViewDelegate {
276247
}
277248

278249
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
279-
let item = timelineItems[row]
280-
281-
measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator))
250+
return rowHeights[row] ?? 60
251+
}
282252

283-
let targetWidth = tableView.tableColumns[0].width
284-
let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude)
253+
func scheduleHeightUpdate() {
254+
guard !pendingHeightUpdate else { return }
255+
pendingHeightUpdate = true
256+
DispatchQueue.main.async { [weak self] in
257+
self?.pendingHeightUpdate = false
258+
self?.updateVisibleRowHeights()
259+
}
260+
}
285261

286-
let size = measurementHostingView.sizeThatFits(in: proposedSize)
287-
// Avoid undefined-height rows which can cause NSTableView layout issues
288-
return max(size.height, 1)
262+
private func updateVisibleRowHeights() {
263+
let visibleRows = tableView.rows(in: tableView.visibleRect)
264+
var changed = IndexSet()
265+
for row in visibleRows.lowerBound..<visibleRows.upperBound {
266+
guard let cellView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false)
267+
as? SelfSizingHostingView<TimelineItemRowView> else { continue }
268+
let h = cellView.measureHeight()
269+
if h > 0, abs(h - (rowHeights[row] ?? 60)) > 1 {
270+
rowHeights[row] = h
271+
changed.insert(row)
272+
}
273+
}
274+
if !changed.isEmpty {
275+
NSAnimationContext.beginGrouping()
276+
NSAnimationContext.current.duration = 0
277+
tableView.noteHeightOfRows(withIndexesChanged: changed)
278+
NSAnimationContext.endGrouping()
279+
}
289280
}
290281
}
291282

@@ -295,3 +286,28 @@ class BottomStickyTableView: NSTableView {
295286
return false
296287
}
297288
}
289+
290+
class SelfSizingHostingView<Content: View>: NSHostingView<Content> {
291+
weak var controller: TimelineViewController?
292+
var row: Int = 0
293+
294+
override func layout() {
295+
super.layout()
296+
controller?.scheduleHeightUpdate()
297+
}
298+
299+
override func invalidateIntrinsicContentSize() {
300+
// Don't call super — prevents constraint loop
301+
// Instead schedule a deferred height update
302+
controller?.scheduleHeightUpdate()
303+
}
304+
305+
func measureHeight() -> CGFloat {
306+
let width = frame.width
307+
guard width > 0 else { return 0 }
308+
let controller = NSHostingController(rootView: rootView)
309+
controller.sizingOptions = [.preferredContentSize]
310+
let size = controller.sizeThatFits(in: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
311+
return max(size.height, 1)
312+
}
313+
}

0 commit comments

Comments
 (0)