8

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.

Bill Waggoner
  • 452
  • 3
  • 11
  • Show the code that you're using; trying to describe it isn't helpful. – l'L'l Feb 09 '17 at 17:33
  • What I have now is a confusing mess. What I am looking for is someone else's code that I can start with. If you really think it would be helpful to post my code then I will try to simplify it and post it but I am working blind and nothing that I have done yet has worked properly. – Bill Waggoner Feb 09 '17 at 18:07
  • Your description and code look like a button inside a view inside a view inside a view -> infinite recursion. `initWithCoder` calls `loadNibNamed` which calls `initWithCoder`. What are you trying to accomplish? – Willeke Feb 10 '17 at 15:18
  • I am trying to create a custom reusable control. What I want to end up with is an image and text field tied together with a little code. I want to be able to use it in Interface Builder. – Bill Waggoner Feb 10 '17 at 15:48

5 Answers5

17

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.

Bill Waggoner
  • 452
  • 3
  • 11
  • Thanks, that really helped me out! If you're interested, I submitted a pull request that shortens the for loop that copies the constraints. – yesthisisjoe Apr 23 '17 at 23:12
  • You're welcome. I've merged the pull request. Thanks for that. – Bill Waggoner Apr 25 '17 at 19:39
  • Does the @IBDesignable actually cause your custom view to show up in IB? I get "Failed to render Instance of ... :The agent crashed". – wcochran Jun 15 '17 at 00:36
  • Yes, it does appear. I also had problems with the agent building it. Watch out for unexpected nil values. When it's built for IB there are nil instances that you will not see in the 'real' version. – Bill Waggoner Jun 15 '17 at 19:26
  • I have used your solution but the view is not rendered on interface builder at design time. – Duck Sep 24 '17 at 00:12
  • 1
    I haven't played with it in XCode 9 but if you use @IBDesignable it ought to show up. Check the build logs for failures in IB. Be doubly careful with unexpected nil values. – Bill Waggoner Sep 25 '17 at 02:57
  • Thank you for such a thorough example on GitHub. It really helped for me to be able to load the project into XCode and compare my project with the example. In the latest XCode I got a build error related to README.md, but I renamed the extension on it, and that worked around the problem. Hopefully I'll find time to update your example and submit changes to github. Thanks again! – Chris Quenelle Sep 28 '18 at 23:16
  • @BillWaggoner Thank you for the example. Works great. I have a question, why adding topView as a subview doesn't work? – Ruzard May 14 '19 at 12:46
  • @Ruzard I haven’t looked at this in a long time. I would hope that it is handled natively now but I don’t know the answer to your question. Sorry ... – Bill Waggoner May 17 '19 at 01:40
  • @BillWaggoner Thanks for your answer! It really helps! But I wonder if there is a easier way to create a new custom view? For example, can we simplify the code that translate the constraints? – david Jan 19 '20 at 18:41
  • @david This problem and solution are actually pretty old. When I talked to Apple over two years ago I think they realized how hard this was to do and it would not surprise me at all if they have made it much easier. And with SwiftUI you can now get previews of your UI live as you write the code so there is that path to consider also. – Bill Waggoner Jan 20 '20 at 21:01
12

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.

Oskar
  • 3,625
  • 2
  • 29
  • 37
2

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.

samwize
  • 25,675
  • 15
  • 141
  • 186
1

You have to add the constraints in your xib first, or it wont work. I had the same problem. Code is just recreating those constrains, but if they weren't added in the first place it has nothing to reconstruct.

You have to also add following two lines right after super.init(coder: coder)

wantsLayer = true
canDrawSubviewsIntoLayer = true

I tested it. It works!

Gypsie
  • 78
  • 6
1

For anyone who arrived at this page but found the accepted answers seemed to be way too complicated for what they were trying to achieve...

I found this video helpful: https://www.youtube.com/watch?v=Iwm47M7st14&t=648s

In a nutshell, using background color as an example:

import Cocoa
@IBDesignable
class SelectionView: NSView {
    
    @IBInspectable var backgroundColor: NSColor? {
        didSet { needsDisplay = true }
    }
    
    override var wantsUpdateLayer: Bool {
        return true
    }
    
    override func updateLayer() {
        guard let layer = layer else { return }
        layer.backgroundColor = backgroundColor?.cgColor
    }
} 

...and just setting the name of the class of the custom view in Interface Builder to the name of the custom NSView subclass you have created (SelectionView in the example code above).

chemFour
  • 140
  • 7
  • This is not an answer to the question. It is a copy of [Best way to change the background color for an NSView](https://stackoverflow.com/a/7635195). Please delete. – Willeke May 25 '22 at 07:15
  • I guess if you really feel strongly about it I can delete but I would say that the question itself does not specify a NIB implementation, so naturally people arrive at this thread who are not using this approach and are looking for something quicker - the background color code is just an example. This is evidenced by the fact this answer has been upvoted in the past, even if it seems to have been downvoted since! :) – chemFour May 26 '22 at 09:06