NSScrollView encapsulating NSCollectionView always scrolling back when resizing

476 Views Asked by At

The program opens a window containing a collection view with 1000 items. If I scroll a bit down, then resize the window by whatever amount in any direction, the scroll view will immediately jump back to the top.

The application is built without Storyboard or XIB. The problem does not occur when building a similar app via Interface Builder. There seems to be something missing, something Interface Builder configures by default. I am testing this with Xcode 12.4 on macOS 11.2.3. Any ideas?

To make it easy to reproduce I have packed everything in a file main.swift.:

import Cocoa

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

class AppDelegate: NSObject, NSApplicationDelegate, NSSplitViewDelegate {
    var window: NSWindow?
    var dataSource: CollectionViewDataSource?
    var collectionView: NSCollectionView?
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let screenSize = NSScreen.main?.frame.size ?? NSSize(width: 1920, height: 1080)
        window = NSWindow(contentRect: NSMakeRect(screenSize.width/4, screenSize.height/4, screenSize.width/2, screenSize.height/2),
                          styleMask: [.miniaturizable, .closable, .resizable, .titled],
                          backing: .buffered,
                          defer: false)
        window?.makeKeyAndOrderFront(nil)
        
        if let view = window?.contentView {
            collectionView = NSCollectionView(frame: NSZeroRect)
            dataSource = CollectionViewDataSource()
            let scrollView = NSScrollView(frame: NSZeroRect)
            view.addSubview(scrollView)
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                scrollView.topAnchor.constraint(equalTo: view.topAnchor),
                scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            ])
            scrollView.documentView = collectionView
            
            collectionView?.collectionViewLayout = NSCollectionViewFlowLayout()
            collectionView?.register(CollectionViewItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier(rawValue: "CollectionViewItem"))
            collectionView?.dataSource = dataSource
        }
    }
}

class CollectionViewDataSource: NSObject, NSCollectionViewDataSource {
    func numberOfSections(in collectionView: NSCollectionView) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return 1000
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        let i = collectionView.makeItem(withIdentifier:NSUserInterfaceItemIdentifier(rawValue: "CollectionViewItem"), for:indexPath) as! CollectionViewItem
        i.view.wantsLayer = true
        i.view.layer?.backgroundColor = NSColor.init(colorSpace: NSColorSpace.deviceRGB, hue: CGFloat(Float.random(in: 0..<1)), saturation: CGFloat(Float.random(in: 0.4...1)), brightness: CGFloat(Float.random(in: 0.5...1)), alpha: 1).cgColor
        return i
    }
    
    func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, at indexPath: IndexPath) -> NSView {
        fatalError("Not implemented")
    }
}

class CollectionViewItem : NSCollectionViewItem {
    override func loadView() {
        self.view = NSView(frame: NSZeroRect)
    }
}

3

There are 3 best solutions below

0
Brett On BEST ANSWER

Move the line

collectionView?.collectionViewLayout = NSCollectionViewFlowLayout()

before the line

scrollView.documentView = collectionView

Had the same problem on my own project and this fixed it. Tested with your code above and fix works there as well.

1
Willeke On

The source code of a collection view in a storyboard:

<collectionView id="yh4-in-fLt">
    <rect key="frame" x="0.0" y="0.0" width="480" height="158"/>
    <autoresizingMask key="autoresizingMask" widthSizable="YES"/>
    <collectionViewFlowLayout key="collectionViewLayout" minimumInteritemSpacing="10" minimumLineSpacing="10" id="TGK-mX-O4r">
        <size key="itemSize" width="50" height="50"/>
    </collectionViewFlowLayout>
    <color key="primaryBackgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
</collectionView>

but the autoresizingMask is not visible in IB. Setting autoresizingMask fixes the issue.

collectionView.autoresizingMask = [.width]
2
C. Ocoa On

Ignoring the frame change notification seems to do the trick.

Add a custom NSClipView as content view of the NSScrollView like so:

scrollView.contentView = ClipViewIgnoringFrameChange()

where ClipViewIgnoringFrameChange() is defined like this:

class ClipViewIgnoringFrameChange : NSClipView {
    override func viewFrameChanged(_ notification: Notification) {}
}