1

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

Paul
  • 115
  • 2
  • 10

1 Answers1

1

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),
        ])
    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • I see what you mean here. I'm running into lots of different problems when I put the idea into the bigger picture though. I think I may need to expand my question a bit. Would it be better to start a new question or would you be okay with adding to this answer? – Paul Mar 25 '20 at 19:55
  • I suppose that depends on what the new problems are... If ***this*** answer resolves ***this*** question for you (i.e. you can properly use this example), then I'd say accept it and start a new question. Otherwise, if your follow-up question can be asked here as a comment, then ask here. – DonMag Mar 25 '20 at 20:05
  • I'll work with this for a while and see if it starts to make sense. It seems like the NibLoadable extension is not the best place to put the layout stuff. I want to make the AxesView draggable, so would there be a better place to add constraints, like ViewDidLoad? – Paul Mar 25 '20 at 20:21
  • @Paul - do you mean you want to drag the h/v lines? Or you want to make the view smaller than the full screen, and you want to drag it around? – DonMag Mar 25 '20 at 20:35
  • I added my full ViewController code. The idea is that anywhere the screen is touched, the Axes go to that point. And clicking on AxesView.xib I'm trying to be able to use the UserDefinedRuntimeAttributes in there, I don't need to see them in Main.storyboard for the custom view. – Paul Mar 25 '20 at 21:04
  • If you are using `frame` and `autoresizingMask`, you should make sure to also set `translatesAutoresizingMaskIntoConstraints. = true`. – Sulthan Mar 25 '20 at 21:23
  • @Sulthan - when loading a NIB, `.translatesAutoresizingMaskIntoConstraints` property of the root view is `true` – DonMag Mar 25 '20 at 21:38
  • 1
    @Paul - see the edits at the bottom of my answer. There are other ways to implement "draggable-cross-hairs," such as overriding the `draw()` method, or using shape layers, for example. If this works for you, though, might as well stick with it. – DonMag Mar 25 '20 at 21:56
  • @DonMag thank you, I will work on your suggestions for a while. I think I'm trying to learn too many things at one time and I might need to back up a little bit and slow down. – Paul Mar 25 '20 at 22:05
  • @DonMag I don't see why when I click on AxesView.xib in project navigator, I can never see any of the IBInspectable options, is that just the way it works? The only place to use them is in the storyboard? I keep expecting that I will be able to set up an xib file and be able to preview it without having to go back to the storyboard, is that possible? – Paul Mar 25 '20 at 23:50
  • 1
    @Paul - that's *exactly* how `#IBDesignable` is supposed to work. You create a UI object that acts a bit like a "template." When you add it to a Storyboard - or instantiate it via code - you can then change the custom `@IBInspectable` properties you've designed. If you want to modify elements - such as colors - *before* you get to the Storyboard phase, you'd need to create multiple (different) xib files... which kinda defeats the purpose of having a designable object. – DonMag Mar 26 '20 at 12:00
  • @DonMag That part really clears up most of the problem I thought I was having. I really appreciate the extra addition to your answer because I had the feeling that the constraints should also have been outlets also and that the touches should change the constraints, so that would have been my next thing to figure out. Now I can see how it's all supposed to work together. – Paul Mar 26 '20 at 12:30
  • 1
    @Paul - another note which you may, or may not, find helpful. I just update the `updateCenter()` func with a little additional code to prevent the lines from moving outside the bounds of the view. That, combined with the touch code being *inside* the class, allows you to create an `AxesView` that does not cover the entire screen (if you ever need to do that). – DonMag Mar 26 '20 at 12:34
  • @DonMag Last question, about the way I'm loading the nib here. I pieced it together from answers on SO, is there anything about it that could be significantly improved? – Paul Mar 26 '20 at 13:28
  • 1
    @Paul - there are various approaches to loading from `xib` files... all with good and bad points. Personally, I don't use them, as I find it easier to write my custom classes with code-only. See the **EDIT 2** at the bottom of my answer for another approach to your "axesView". It's implemented with code-only (no xib) and uses `CALayer` instead of subviews. – DonMag Mar 26 '20 at 15:21
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/210394/discussion-between-paul-and-donmag). – Paul Mar 26 '20 at 17:09