Last active
March 17, 2025 22:45
-
-
Save jaanus/66e3d863941ba645c88220b8a22970e1 to your computer and use it in GitHub Desktop.
NSCollectionView that responds correctly to window resizing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A video of the behavior where you see window resizing that causes cell overlap:
mac.collection.view.resizing.mov