Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use custom NSView in Interface Builder?

I have watched numerous tutorials on how to create a custom View in Interface Builder. All are for iOS but MacOS should be similar, no? I have tried a few methods but none are completely successful. The init(coder:) calls the NIB instantiation (either through Bundle.main.loadNibNamed or NSNib which, in turn, calls init(coder:) and ends up with infinite recursion if I class the main view in my nib as my custom class

If I use a standard class then make file's owner my custom class that works better but still isn't right.

Is there an example that creates a custom control, using AppKit, that works? The closest that I have come displays the custom control but none of the autolayout settings work.

It must be fairly simple but I haven't figured it out yet.

Here is what I have so far:

  1. A new class MyControl


import Cocoa

@IBDesignable class MyControl: NSView {

@IBOutlet var customView: NSView!  // The top level NSView
@IBOutlet weak var insideButton: NSButton!  // The button inside the view

let myName: String

required init?(coder: NSCoder) {
    super.init(coder: coder)

    if Bundle.main.loadNibNamed("MyControl", owner: self, topLevelObjects: nil) {
        addSubview(customView)
    }
}

}

  1. A nib based on NSView with contains a centered NSButton. The File's Owner class is set to MyControl, the top level view remains as NSView

  2. The Main.storyboard has a Custom View classed as MyControl centered with height and width set.

When I view Main.storyboard it has a frame for the custom view but it is blank.

When I run the application the window that displays is blank.

like image 358
Bill Waggoner Avatar asked Feb 09 '17 17:02

Bill Waggoner


3 Answers

After much searching and a lot of help from Apple Support I have found that creating and using a custom control is very easy in AppKit. It's just that it is like a key in the lock, unless you get it right you won't get much at all.

I have created a sample project and posted it to GitHub here: https://github.com/ctgreybeard/SwiftCustomControl

It's a small project and I hope I have fully commented it so that someone else can understand it.

The gist of the process is this:

  1. In Interface Builder create a XIB and a subclass of NSView. They should be the same name but this is not required.
  2. For the XIB change the class of File's Owner to your new class' name.
  3. Build your new custom control as you want it to be.
  4. Include an IBOutlet in your class referencing the top-level NSView in the XIB. Also include any other actions or outlets that your control needs.
  5. Create the initializer required init?(coder: coder)
  6. Within that initializer:
    1. Load the nib using let newNib = NSNib(nibNamed: myName, bundle: Bundle(for: type(of: self))) where myName is the name of the XIB.
    2. newNib.instantiate(withOwner: self, topLevelObjects: nil) the new NSNib
    3. Recreate all the existing constraints from the old top-level NSView replacing the old NSView with self. Do this in a for loop over the constraints property. Alternatively you can simply create the constraints as you know them to be.
    4. self.addSubview for all the old top-level subviews This is easily done in a for loop over the subviews array in the old NSView.
    5. Apply the new array of constraints you created above.

You're done ... the custom control should now appear correctly in Interface Builder and the app.

Commentary: This, as simple as it is, really shouldn't be necessary. I should be able to simply use my custom class name in the top-level NSView in the XIB and be done with it. The code in the init is simply replacing that top-level NSView with our custom view.

like image 74
Bill Waggoner Avatar answered Nov 13 '22 10:11

Bill Waggoner


Here's some code to go with Bills solution:

We can create a protocol LoadableNib to have our custom views conform to the requirements and extend it to get this functionality if it does:

protocol LoadableNib {
    var contentView: NSView! { get }
}

extension LoadableNib where Self: NSView {

    func loadViewFromNib() {
        let bundle = Bundle(for: type(of: self))
        let nib = NSNib(nibNamed: .init(String(describing: type(of: self))), bundle: bundle)!
        _ = nib.instantiate(withOwner: self, topLevelObjects: nil)

        let contentConstraints = contentView.constraints
        contentView.subviews.forEach({ addSubview($0) })

        for constraint in contentConstraints {
            let firstItem = (constraint.firstItem as? NSView == contentView) ? self : constraint.firstItem
            let secondItem = (constraint.secondItem as? NSView == contentView) ? self : constraint.secondItem
            addConstraint(NSLayoutConstraint(item: firstItem as Any, attribute: constraint.firstAttribute, relatedBy: constraint.relation, toItem: secondItem, attribute: constraint.secondAttribute, multiplier: constraint.multiplier, constant: constraint.constant))
        }
    }
}

Then in our custom view class:

class YourViewClass: NSView, LoadableNib {

    @IBOutlet var contentView: NSView!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        loadViewFromNib()
    }
}

Don't forget the xib setup as per Bill's answer (class of File's Owner to your custom view class, and a container NSView to hold the view contents and connect to the contentView outlet.

like image 12
Oskar Avatar answered Nov 13 '22 10:11

Oskar


Essential steps:

  1. MyView.swift looks like below
  2. Create a xib file named the same, eg MyView.xib
  3. Set File’s Owner > Class > MyView
  4. Connect the root view to the contentView IBOutlet

-

class MyView: NSView {

    @IBOutlet var contentView: NSView!

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        setup()
    }

    required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
        setup()
    }

    private func setup() {
        let bundle = Bundle(for: type(of: self))
        let nib = NSNib(nibNamed: .init(String(describing: type(of: self))), bundle: bundle)!
        nib.instantiate(withOwner: self, topLevelObjects: nil)

        addSubview(contentView)
        // Autolayout code using cartograhpy library
        constrain(self, contentView) { view, subview in
            subview.edges == view.edges
        }
    }

}

It is similar to the steps from Bill, but I think Bill's step 6 can be simplified. The contentView can simply be added to the custom view, and then add a constraint to fill.

Also on my blog post.

like image 2
samwize Avatar answered Nov 13 '22 11:11

samwize