Search code examples
swiftcocoansscrollviewnslayoutanchor

Programmatic NSScrollView with layout anchors - how to fix the scrolling?


Quite a few SO posts on implementing a scroll view programmatically but I haven't found a solution for this case... here I am adding subviews to the document view (with everything laid out using layout anchors), which all works apart from the scrolling.

I think the issue here is that AppKit interprets the constraints in the example to mean there isn't anything to scroll, but I am not sure why...

import Cocoa

class ViewController: NSViewController {

    var scrollView = NSScrollView()
    var contentView = NSClipView()
    var documentView = ParentView()
    var generateButton = NSButton()
    
    
    @objc func generate(_ sender: NSObject) {
         
        let child = ChildView()
        child.translatesAutoresizingMaskIntoConstraints = false
        documentView.addSubview(child)
        documentView.setupChildViewLayout(sv: child)
    }
    
    
    override func viewDidLoad() {
        
        super.viewDidLoad()
        view.addSubview(generateButton)
        view.addSubview(scrollView)
        setupLayout()
    }
    
    
    func setupLayout() {
        
        scrollView.contentView = contentView
        scrollView.documentView = documentView
        
        scrollView.borderType = .lineBorder
        scrollView.hasHorizontalScroller = true
        scrollView.hasVerticalScroller = true
        
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        contentView.translatesAutoresizingMaskIntoConstraints = false
        documentView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addConstraints([
            scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
            scrollView.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -150),
            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 80),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100)
        ])

        NSLayoutConstraint.activate([
            scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
            scrollView.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -150),
            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 80),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100)
        ])

        scrollView.addConstraints([
            contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            contentView.trailingAnchor.constraint(greaterThanOrEqualTo: scrollView.trailingAnchor),
            contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            contentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.bottomAnchor)
        ])

        NSLayoutConstraint.activate([
            contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            contentView.trailingAnchor.constraint(greaterThanOrEqualTo: scrollView.trailingAnchor),
            contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            contentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.bottomAnchor)
        ])
        
        scrollView.addConstraints([
            documentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            documentView.trailingAnchor.constraint(greaterThanOrEqualTo: scrollView.trailingAnchor),
            documentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.bottomAnchor)
        ])

        NSLayoutConstraint.activate([
            documentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            documentView.trailingAnchor.constraint(greaterThanOrEqualTo: scrollView.trailingAnchor),
            documentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.bottomAnchor)
        ])
        
        // Setup anchor constraints for the button
        setupGenerateButton()
        generateButton.translatesAutoresizingMaskIntoConstraints = false
        
        view.addConstraints([
            generateButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
            generateButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30),
            generateButton.heightAnchor.constraint(equalToConstant: 30),
            generateButton.widthAnchor.constraint(equalToConstant: 200)
        ])
        
        NSLayoutConstraint.activate([
            generateButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
            generateButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30),
            generateButton.heightAnchor.constraint(equalToConstant: 30),
            generateButton.widthAnchor.constraint(equalToConstant: 200)
        ])
    }
    
    func setupGenerateButton() {
        generateButton.attributedTitle = NSMutableAttributedString(string: "GenerateChildView", attributes: [NSAttributedString.Key.strokeColor: (NSColor.white), NSAttributedString.Key.font: NSFont.systemFont(ofSize: (NSFont.systemFontSize))])
        generateButton.wantsLayer = true
        generateButton.bezelColor = NSColor(red: 0.2, green: 0.2, blue: 0.6, alpha: 1.0)
        generateButton.action  = #selector(self.generate(_:))
    }
    
}


class ParentView: NSView {
    
    override func draw(_ dirtyRect: NSRect) {
        
        super.draw(dirtyRect)
    }
    
    func setupChildViewLayout(sv: ChildView) {
        
        if (self.subviews.count < 2) {
        
            self.addConstraints([
                sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                sv.topAnchor.constraint(equalTo: self.topAnchor)
            ])
            
            NSLayoutConstraint.activate([
                sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                sv.topAnchor.constraint(equalTo: self.topAnchor)
            ])
        }
        
        else {
            
            let c = self.subviews.count - 2
            let lastView = self.subviews[c]
            
            self.addConstraints([
                sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                sv.topAnchor.constraint(equalTo: lastView.bottomAnchor)
            ])
            
            NSLayoutConstraint.activate([
                sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                sv.topAnchor.constraint(equalTo: lastView.bottomAnchor)
            ])
        }
    }
}


class ChildView: NSView {
    
    override var intrinsicContentSize: NSSize {
        
        return CGSize(width: 650, height: 200)
    }
    
    override func draw(_ dirtyRect: NSRect) {
        
        super.draw(dirtyRect)
        
        NSColor.gray.set()
        self.bounds.frame()
        
    }
}


Solution

  • You're still pinning the content view and the document view to the scroll view. Only add constraints to the outside of the scroll view and the inside of the document view. Don't add constraints between the content view, the document view and the scroll view. Add constraints between the parent view and its child views so the childs views will fit inside the parent view.

    class ViewController: NSViewController {
    
        var scrollView = NSScrollView()
        var documentView = ParentView()
        var generateButton = NSButton()
        
        @objc func generate(_ sender: NSObject) {
            let child = ChildView()
            child.translatesAutoresizingMaskIntoConstraints = false
            documentView.addSubview(child)
            documentView.setupChildViewLayout(sv: child)
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.addSubview(generateButton)
            view.addSubview(scrollView)
            setupLayout()
        }
        
        func setupLayout() {
            scrollView.documentView = documentView
            
            scrollView.borderType = .lineBorder
            scrollView.hasHorizontalScroller = true
            scrollView.hasVerticalScroller = true
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            documentView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addConstraints([
                scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
                scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -150),
                scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 80),
                scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100)
            ])
            
            // Setup anchor constraints for the button
            setupGenerateButton()
            generateButton.translatesAutoresizingMaskIntoConstraints = false
            
            view.addConstraints([
                generateButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10),
                generateButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30),
                generateButton.heightAnchor.constraint(equalToConstant: 30),
                generateButton.widthAnchor.constraint(equalToConstant: 200)
            ])
        }
        
        func setupGenerateButton() {
            generateButton.attributedTitle = NSMutableAttributedString(string: "GenerateChildView", attributes: [NSAttributedString.Key.strokeColor: (NSColor.white), NSAttributedString.Key.font: NSFont.systemFont(ofSize: (NSFont.systemFontSize))])
            generateButton.wantsLayer = true
            generateButton.bezelColor = NSColor(red: 0.2, green: 0.2, blue: 0.6, alpha: 1.0)
            generateButton.action  = #selector(self.generate(_:))
        }
        
    }
    
    
    class ParentView: NSView {
        
        override func draw(_ dirtyRect: NSRect) {
            super.draw(dirtyRect)
        }
        
        func setupChildViewLayout(sv: ChildView) {
            
            if (self.subviews.count == 1) {
                self.addConstraints([
                    sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                    sv.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor),
                    sv.topAnchor.constraint(equalTo: self.topAnchor),
                    sv.bottomAnchor.constraint(equalTo: self.bottomAnchor)
                ])
            }
            
            else {
                
                let c = self.subviews.count - 2
                let lastView = self.subviews[c]
                
                self.addConstraints([
                    sv.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                    sv.topAnchor.constraint(equalTo: lastView.bottomAnchor)
                ])
    
                let bottomConstraints = self.constraints.filter {
                    $0.firstAttribute == NSLayoutConstraint.Attribute.bottom
                }
                self.removeConstraints(bottomConstraints)
                self.addConstraints([
                    sv.bottomAnchor.constraint(equalTo: self.bottomAnchor)
                ])
            }
        }
    }
    
    
    class ChildView: NSView {
        
        override var intrinsicContentSize: NSSize {
            
            return CGSize(width: 650, height: 200)
        }
        
        override func draw(_ dirtyRect: NSRect) {
            
            super.draw(dirtyRect)
            
            NSColor.gray.set()
            self.bounds.frame()
            
        }
    }