iosinterface-builderibdesignableibinspectable

Trouble with IBInspectable and loading a view from xib for Interface Builder


I have a custom class with its own .xib. I'm trying to incorporate IBInspectable to change the color of its background and subviews' backgrounds in IB.

In IB, when I leave 'Custom Class' blank, and add the User Defined Runtime Attribute 'bgColor' and then try to run the app, I get "...this class is not key value coding-compliant for the key bgColor." The app runs without crashing, but does not apply the color to the view.

When I set 'Custom Class' to "AxesView" and run, app crashes with "Thread 1: EXC_BAD_ACCESS (code=2, address)" and highlights the line "guard let view = Self.nib.instantiate..." in func setupFromNib() in NibLoadableExtension.swift

Is there some way to these things working together, or is this an either/or situation?

In View Controller:

var axesView = AxesView()
override func viewDidLoad() {
    super.viewDidLoad()
    axesView.frame = self.view.bounds
    self.view.addSubview(axesView)
}

Full ViewController.swift

var axesView = AxesView()

override func viewDidLoad() {
    super.viewDidLoad()
    axesView.frame = self.view.bounds

    axesView.bgColor = .clear
    axesView.lineColor = .red
    self.view.addSubview(axesView)
    axesView.contentView.translatesAutoresizingMaskIntoConstraints = false

    axesView.contentView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0).isActive = true
    axesView.contentView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0).isActive = true
    axesView.contentView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
    axesView.contentView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    let touch = touches.first! as UITouch
    let p = touch.location(in: self.view)
    axesView.vLine.center.x = p.x
    axesView.hLine.center.y = p.y
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    let touch = touches.first! as UITouch
    let p = touch.location(in: self.view)
    axesView.vLine.center.x = p.x
    axesView.hLine.center.y = p.y
    self.view.setNeedsDisplay()
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    touchesMoved(touches, with: event)
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    touchesEnded(touches, with: event)
}

AxesView.swift

@IBDesignable class AxesView: UIView, NibLoadable {

    @IBOutlet var contentView: UIView!
    @IBOutlet weak var hLine: UIView!
    @IBOutlet weak var vLine: UIView!

    @IBInspectable var bgColor: UIColor = UIColor.white {
        didSet {
            contentView.backgroundColor = bgColor
        }
    }

    @IBInspectable var lineColor: UIColor = UIColor.cyan {
        didSet {
            hLine.backgroundColor = lineColor
            vLine.backgroundColor = lineColor
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupFromNib()
    }

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

NibLoadableExtension.swift

public protocol NibLoadable {
    static var nibName: String { get }
}

public extension NibLoadable where Self: UIView {

    static var nibName: String {
        return String(describing: Self.self) // defaults to the name of the class implementing this protocol.
    }

    static var nib: UINib {
        let bundle = Bundle(for: Self.self)
        return UINib(nibName: Self.nibName, bundle: bundle)
    }

    func setupFromNib() {
        guard let view = Self.nib.instantiate(withOwner: self, options: nil).first as? UIView else { fatalError("Error loading \(self) from nib") }

        addSubview(view)
    }
}

enter image description here enter image description here


Solution

  • Your code works fine (for the most part).

    Make sure you have set the Class on the correct object:

    enter image description here

    Change your setupFromNib() func to:

    func setupFromNib() {
        guard let view = Self.nib.instantiate(withOwner: self, options: nil).first as? UIView else { fatalError("Error loading \(self) from nib") }
    
        addSubview(view)
        view.frame = self.bounds
        view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    
        // layout code...
    }
    

    Here's how it looks added to a view controller in IB:

    enter image description here

    and here's how it looks via code:

    class AxesTestViewController: UIViewController {
    
        var axesView = AxesView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            axesView.frame = self.view.bounds
            axesView.bgColor = .blue
            axesView.lineColor = .red
            self.view.addSubview(axesView)
        }
    
    }
    

    enter image description here


    EDIT

    After discussion in comments, here is one approach to this @IBDesignable xib with "draggable" cross-hair-lines.

    This uses constraints and modifies the .constant on centerX and centerY to move the lines. I also moved your touches... funcs inside the custom view to keep things a bit more orderly.

    Complete example code follows, including the NibLoadable code (I renamed your control to TapAxesView for comparison):

    public protocol NibLoadable {
        static var nibName: String { get }
    }
    
    public extension NibLoadable where Self: UIView {
    
        static var nibName: String {
            return String(describing: Self.self) // defaults to the name of the class implementing this protocol.
        }
    
        static var nib: UINib {
            let bundle = Bundle(for: Self.self)
            return UINib(nibName: Self.nibName, bundle: bundle)
        }
    
        func setupFromNib() {
            guard let view = Self.nib.instantiate(withOwner: self, options: nil).first as? UIView else { fatalError("Error loading \(self) from nib") }
            addSubview(view)
            view.backgroundColor = .clear
            view.frame = self.bounds
            view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        }
    }
    
    @IBDesignable
    class TapAxesView: UIView, NibLoadable {
    
        @IBOutlet var hLine: UIView!
        @IBOutlet var vLine: UIView!
    
        @IBOutlet var vLineCenterX: NSLayoutConstraint!
        @IBOutlet var hLineCenterY: NSLayoutConstraint!
    
        @IBInspectable var bgColor: UIColor = UIColor.white {
            didSet {
                self.backgroundColor = bgColor
            }
        }
    
        @IBInspectable var lineColor: UIColor = UIColor.cyan {
            didSet {
                hLine.backgroundColor = lineColor
                vLine.backgroundColor = lineColor
            }
        }
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            setupFromNib()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            setupFromNib()
        }
    
        func updateCenter(_ point: CGPoint) -> Void {
            // prevent centers from moving outside the bounds
            let halfW = (bounds.size.width / 2.0)
            let halfH = (bounds.size.height / 2.0)
            let x = point.x - halfW
            let y = point.y - halfH
            vLineCenterX.constant = min(max(x, -halfW), halfW)
            hLineCenterY.constant = min(max(y, -halfH), halfH)
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            let touch = touches.first! as UITouch
            let p = touch.location(in: self)
            updateCenter(p)
        }
    
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            let touch = touches.first! as UITouch
            let p = touch.location(in: self)
            updateCenter(p)
        }
    
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            touchesMoved(touches, with: event)
        }
    
    }
    
    class TapAxesTestViewController: UIViewController {
    
        var axesView = TapAxesView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            axesView.frame = self.view.bounds
    
            axesView.bgColor = .clear
            axesView.lineColor = .red
            self.view.addSubview(axesView)
    
            axesView.translatesAutoresizingMaskIntoConstraints = false
    
            axesView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0).isActive = true
            axesView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0).isActive = true
            axesView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
            axesView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
        }
    
    }
    

    and here is the source for the TapAxesView.xib file:

    <?xml version="1.0" encoding="UTF-8"?>
    <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
        <device id="retina4_7" orientation="portrait" appearance="light"/>
        <dependencies>
            <deployment identifier="iOS"/>
            <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/>
            <capability name="Safe area layout guides" minToolsVersion="9.0"/>
            <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
        </dependencies>
        <objects>
            <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="TapAxesView" customModule="MiniScratch" customModuleProvider="target">
                <connections>
                    <outlet property="hLine" destination="wry-9o-V8F" id="uCc-eL-sSS"/>
                    <outlet property="hLineCenterY" destination="Txd-hz-fX2" id="OUH-HO-ghG"/>
                    <outlet property="vLine" destination="x0E-M7-ETl" id="BaY-4q-4RA"/>
                    <outlet property="vLineCenterX" destination="pAM-XU-BDo" id="fgf-lE-dn3"/>
                </connections>
            </placeholder>
            <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
            <view contentMode="scaleToFill" id="iN0-l3-epB">
                <rect key="frame" x="0.0" y="0.0" width="375" height="382"/>
                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                <subviews>
                    <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wry-9o-V8F">
                        <rect key="frame" x="0.0" y="189" width="375" height="4"/>
                        <color key="backgroundColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
                        <constraints>
                            <constraint firstAttribute="height" constant="4" id="OqP-vn-hAj"/>
                        </constraints>
                    </view>
                    <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x0E-M7-ETl">
                        <rect key="frame" x="185.5" y="0.0" width="4" height="382"/>
                        <color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
                        <constraints>
                            <constraint firstAttribute="width" constant="4" id="cqZ-JL-4vH"/>
                        </constraints>
                    </view>
                </subviews>
                <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                <constraints>
                    <constraint firstItem="wry-9o-V8F" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="Txd-hz-fX2"/>
                    <constraint firstItem="wry-9o-V8F" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" id="bcB-iZ-vbV"/>
                    <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="x0E-M7-ETl" secondAttribute="bottom" id="hfl-4K-VZq"/>
                    <constraint firstItem="wry-9o-V8F" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="ma5-u8-0U4"/>
                    <constraint firstItem="x0E-M7-ETl" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="pAM-XU-BDo"/>
                    <constraint firstItem="x0E-M7-ETl" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="z8W-cZ-2Bi"/>
                </constraints>
                <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
                <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
                <point key="canvasLocation" x="138.40000000000001" y="24.287856071964018"/>
            </view>
        </objects>
    </document>
    

    EDIT 2

    Maybe worth trying... a custom @IBDesignable view via code only ... no xib file (or nib-loading) needed. Also, this uses CALayer for the "cross-hair-lines" instead of subviews. Makes it a little "lighter weight."

    @IBDesignable
    class LayerAxesView: UIView {
    
        var hLine: CALayer = CALayer()
        var vLine: CALayer = CALayer()
    
        var curX: CGFloat = -1.0
        var curY: CGFloat = -1.0
    
        let lineWidth: CGFloat = 4.0
    
        @IBInspectable var bgColor: UIColor = UIColor.white {
            didSet {
                self.backgroundColor = bgColor
            }
        }
    
        @IBInspectable var lineColor: UIColor = UIColor.cyan {
            didSet {
                hLine.backgroundColor = lineColor.cgColor
                vLine.backgroundColor = lineColor.cgColor
            }
        }
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
    
        override func prepareForInterfaceBuilder() {
            commonInit()
        }
    
        func commonInit() -> Void {
            if hLine.superlayer == nil {
                layer.addSublayer(hLine)
                layer.addSublayer(vLine)
                hLine.backgroundColor = lineColor.cgColor
                vLine.backgroundColor = lineColor.cgColor
                backgroundColor = bgColor
            }
        }
    
        override func layoutSubviews() {
            // if curX and curY have not yet been set,
            //    such as on init or when used in Storyboard / IB,
            //    initialize to center of view
            if curX == -1 {
                curX = bounds.midX
                curY = bounds.midY
            }
            hLine.frame = CGRect(x: bounds.minX, y: curY - lineWidth * 0.5, width: bounds.maxX, height: lineWidth)
            vLine.frame = CGRect(x: curX - lineWidth * 0.5, y: bounds.minY, width: lineWidth, height: bounds.maxY)
        }
    
        func updateCenter(_ point: CGPoint) -> Void {
            // prevent centers from moving outside the bounds
            curX = max(min(bounds.maxX, point.x), bounds.minX)
            curY = max(min(bounds.maxY, point.y), bounds.minY)
            // disable CALayer's built-in animation
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            setNeedsLayout()
            layoutIfNeeded()
            CATransaction.commit()
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            let touch = touches.first! as UITouch
            let p = touch.location(in: self)
            updateCenter(p)
        }
    
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            let touch = touches.first! as UITouch
            let p = touch.location(in: self)
            updateCenter(p)
        }
    
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            touchesMoved(touches, with: event)
        }
    
    }
    
    class TapAxesTestViewController: UIViewController {
    
        var axesView: LayerAxesView = {
            let v = LayerAxesView()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.lineColor = .red
            v.bgColor = UIColor.blue.withAlphaComponent(0.5)
            return v
        }()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            self.view.addSubview(axesView)
    
            // respect safe area
            let g = view.safeAreaLayoutGuide
    
            // constrain axesView to safe area with 40-pts "padding"
            NSLayoutConstraint.activate([
                axesView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                axesView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                axesView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                axesView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            ])
        }
    
    }