Skip to content

Instantly share code, notes, and snippets.

@jaanus
Last active March 17, 2025 22:45
Show Gist options
  • Save jaanus/66e3d863941ba645c88220b8a22970e1 to your computer and use it in GitHub Desktop.
Save jaanus/66e3d863941ba645c88220b8a22970e1 to your computer and use it in GitHub Desktop.
NSCollectionView that responds correctly to window resizing
import SwiftUI
import OSLog
/// A collection view that displays a simple scrolling list of SwiftUI items.
///
/// The collection view content should be a simple column, displaying SwiftUI items. The collection view items (particularly heights,
/// because widths are already always full width) should automatically always scale to the SwiftUI item content.
///
/// A lot of this is reusable on iOS. But on macOS, we can resize the window to make the collection view respond to size changes,
/// so initial experimentation is only on macOS.
///
/// The intent is to get the UI right with static content, and then actually start driving it with content.
///
/// Currently, this UI is already pretty good. The one wart is that if the window becomes too narrow so that text starts to wrap,
/// the item heights do not respond to that. SwiftUI views do resize and layout themselves correctly, but collection view items do not
/// add the needed height to neatly fit the SwiftUI view into the cell, resulting in overflow and overlap.
struct MacCollectionView: NSViewRepresentable {
private let logger = Logger(subsystem: "SwiftUICD", category: "MacCollectionView")
func makeNSView(context: Context) -> NSScrollView {
logger.debug("makeNSView.")
// Configure the collection view
let collectionView = context.coordinator.collectionView
collectionView.collectionViewLayout = compositionalLayout
collectionView.isSelectable = false
collectionView.backgroundColors = [.clear]
// Register the item class
collectionView.register(NSCollectionViewItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Cell"))
// Set up the scroll view to contain the collection view
let scrollView = createScrollView(for: context)
setupWindowResizeHandling(for: context.coordinator.collectionView, scrollView: scrollView)
context.coordinator.setupDatasource()
// Update layout after applying the initial snapshot
// Without this, the initial layout and item sizes are not correct
// until you start interacting with the view and do something like resize a window
DispatchQueue.main.async {
collectionView.collectionViewLayout?.invalidateLayout()
}
// Initial layout is incorrect.
// The above async call produces a correct layout after a while. Out of the box,
// the user would see “flash of incorrect initial layout”.
// To make the UI less jarring and not show this flash, initial alpha is 0, the correct
// layout is produced by the above async call, and we then fade in the view.
// We hide it with this alpha thing
scrollView.alphaValue = 0
// Animate in after a short delay to allow layout to complete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
scrollView.animator().alphaValue = 1.0
}
}
return scrollView
}
private func createScrollView(for context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.documentView = context.coordinator.collectionView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
return scrollView
}
/// Make the view respond correctly to window resize handling.
///
/// Make the topmost item view pinned to top of window, unless bottom view is visible.
///
/// If bottom view is visible, make it pinned to the bottom of window.
private func setupWindowResizeHandling(
for collectionView: NSCollectionView,
scrollView: NSScrollView
) {
var anchorIndexPath: IndexPath?
var isFirstResize = true
var shouldPinToBottom = false
// Async, to have the scrollview window available
DispatchQueue.main.async {
guard let window = scrollView.window else { return }
NotificationCenter.default.addObserver(
forName: NSWindow.willStartLiveResizeNotification,
object: window,
queue: nil
) { _ in
let visibleItems = collectionView.indexPathsForVisibleItems().sorted()
guard !visibleItems.isEmpty else { return }
// Check if the last item is visible
let lastItemPath = IndexPath(item: collectionView.numberOfItems(inSection: 0) - 1, section: 0)
shouldPinToBottom = visibleItems.contains(lastItemPath)
// Store either the bottom or top index path
anchorIndexPath = shouldPinToBottom ? visibleItems.last : visibleItems.first
isFirstResize = true
logger.debug("Resize started, pinning to \(shouldPinToBottom ? "bottom" : "top") at index \(anchorIndexPath?.item ?? -1)")
}
NotificationCenter.default.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: nil
) { _ in
guard let indexPath = anchorIndexPath else { return }
let scrollPosition: NSCollectionView.ScrollPosition = shouldPinToBottom ? .bottom : .top
if isFirstResize {
// First resize event: animate to position
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.allowsImplicitAnimation = true
collectionView.animator().scrollToItems(
at: [indexPath],
scrollPosition: scrollPosition
)
}
isFirstResize = false
} else {
// Subsequent resize events: instant updates
collectionView.scrollToItems(at: [indexPath], scrollPosition: scrollPosition)
}
}
NotificationCenter.default.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: nil
) { _ in
anchorIndexPath = nil
isFirstResize = true
shouldPinToBottom = false
}
}
}
var compositionalLayout: NSCollectionViewCompositionalLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(50)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(50)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 8 // spacing between items
let layout = NSCollectionViewCompositionalLayout(section: section)
return layout
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
logger.debug("updateNSView.")
}
func makeCoordinator() -> CVCoordinator {
CVCoordinator()
}
}
class CVCoordinator {
private let logger = Logger(subsystem: "SwiftUICD", category: "MacCollectionView")
let collectionView: NSCollectionView
var diffableDataSource: NSCollectionViewDiffableDataSource<Int, Int>?
init() {
logger.debug("CVCoordinator init")
collectionView = NSCollectionView()
}
func setupDatasource() {
logger.debug("setupDatasource")
diffableDataSource = NSCollectionViewDiffableDataSource<Int, Int>(
collectionView: collectionView,
itemProvider: {
[weak self] collectionView, indexPath, itemIdentifier -> NSCollectionViewItem? in
// self?.logger.debug("diffableDataSource item block for index path \(indexPath)")
// Dequeue a reusable cell
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Cell"), for: indexPath)
// Configure the item
if item.view.subviews.isEmpty {
// There is no hosting view created. Create the hosting view and constraints.
item.view.wantsLayer = true
item.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.2).cgColor
self?.setupSwiftUIContent(item: item, indexPath: indexPath, collectionView: collectionView)
} else if let hostingView = item.view.subviews.first as? NSHostingView<CollectionItemView> {
// Hosting view and its constraints are already available and configured.
// Install the correct SwiftUI view into the hosting view.
hostingView.rootView = (self?.makeSwiftUIContent(for: indexPath, collectionView: collectionView))!
}
return item
}
)
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
// Populate the snapshot.
snapshot.appendSections([0])
snapshot.appendItems([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// Apply the snapshot.
diffableDataSource!.apply(snapshot, animatingDifferences: false)
}
private func makeSwiftUIContent(for indexPath: IndexPath, collectionView: NSCollectionView) -> CollectionItemView {
CollectionItemView(index: indexPath.item)
}
private func setupSwiftUIContent(item: NSCollectionViewItem, indexPath: IndexPath, collectionView: NSCollectionView) {
let hostingView = NSHostingView<CollectionItemView>(rootView: makeSwiftUIContent(for: indexPath, collectionView: collectionView))
hostingView.translatesAutoresizingMaskIntoConstraints = false
item.view.addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: item.view.topAnchor),
hostingView.leadingAnchor.constraint(equalTo: item.view.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: item.view.trailingAnchor),
hostingView.bottomAnchor.constraint(equalTo: item.view.bottomAnchor)
])
}
deinit {
logger.debug("CVCoordinator deinit")
}
}
/// SwiftUI view for one row
private struct CollectionItemView: View {
let index: Int
@State private var showExtraThing = false
var body: some View {
VStack(alignment: .leading) {
Text("Hello collection. Item: \(index)")
.padding(.top, 50 + CGFloat(20 * index))
.background(Color.blue.opacity(0.2))
Button(
action: {
showExtraThing.toggle()
}, label: {
Text("Show extra thing")
}
)
if (showExtraThing) {
// Collection view does not automatically recompute the item sizes when this item appears.
// But if you scroll away and then back, you see that the item does have a correct height.
Text("Hi. I am some extra text")
.padding(.vertical)
}
Text("I am another text. I am longer so I will wrap to multiple lines when you make the window width smaller.")
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.background(Color.cyan.opacity(0.2))
}
}
@jaanus
Copy link
Author

jaanus commented Mar 17, 2025

A video of the behavior where you see window resizing that causes cell overlap:

mac.collection.view.resizing.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment