iosswiftibdesignablelayoutsubviews

IBDesignable rendered at the top left of the screen instead of their correct position


I have a custom implementation of a checkbox / radio button in Swift. The class is annotated as @IBDesignable. I implement layoutSubviews and prepareForInterfaceBuilder.

This implementation is distributed in a pod that is used in several applications. I know that Cocoapods has caused and is still causing some inconsistencies in the IB behavior, but I don't believe that it is part of the issue here.

Everything is fine when I run the application. However, when I use the interface builder, the box is always rendered on the top left of the parent view instead of being in its own view.

How can I fix this? Is my implementation of layoutSubviews and prepareForInterfaceBuilder relevant?

EDIT: I made a sample project here: https://github.com/csarkis/TestIBDesignablesPod/

Just run pod install and open the project.

Here is a simplified rendition of the designable view:

class RadioButton: UIControl {
    /// Radio button radius
    private var radioButtonSize: CGFloat = 20

    /// A Boolean value that determines the off/on state of the radio button. Default is on
    @IBInspectable public var isOn: Bool = true { ... }

    ...

    // MARK: Initialisers
    /// :nodoc:
    public override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }

    /// :nodoc:
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupViews()
    }

    /// Setup the view initially
    private func setupViews() {
        self.subviews.forEach { $0.removeFromSuperview() }
        self.translatesAutoresizingMaskIntoConstraints = false
        self.autoresizesSubviews = false

        self.heightAnchor.constraint(equalToConstant: 40).isActive = true
        self.widthAnchor.constraint(equalToConstant: 40).isActive = true

        prepareForInterfaceBuilder()
        setNeedsDisplay()
    }

    /// Main color of the radio button element
    private var color: UIColor { ... }

    // MARK: View management
    // MARK: Custom drawings
    /// Width of the unchecked radio circle
    private var borderWidth: CGFloat = 2

    /// :nodoc:
    public override func draw(_ rect: CGRect) {
        super.draw(rect)

        let context = UIGraphicsGetCurrentContext()!
        context.setStrokeColor(self.color.cgColor)
        context.setFillColor(self.color.cgColor)
        context.setLineWidth(self.borderWidth)

        drawRadio(in: rect, for: context)
    }

    private func drawRadio(in rect: CGRect, for context: CGContext) {
        // Radio button
        // Draw inside the box, considering the border width
        let newRect = rect.insetBy(dx: (rect.width - radioButtonSize + borderWidth) / 2,
                                   dy: (rect.height - radioButtonSize + borderWidth) / 2)

        // Draw the outlined circle
        let borderCircle = UIBezierPath(ovalIn: newRect)
        borderCircle.stroke()
        context.addPath(borderCircle.cgPath)
        context.strokePath()
        context.fillPath()

        ...
    }

    /// :nodoc:
    public override func layoutSubviews() {
        super.layoutSubviews()

        self.setNeedsDisplay()
    }

    /// :nodoc:
    public override var intrinsicContentSize: CGSize {
        CGSize(width: 40, height: 40)
    }

    /// :nodoc:
    public override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        self.setNeedsDisplay()
    }

    ...
}

My observation on this simple pod is that the behavior is very inconsistant (a lot of crashes and random errors). Here, the checkbox is first rendered on the top left and moves to its correct position as soon as we modify any attribute. The class is RadioButton.swift.


Solution

  • The issue is translatesAutoresizingMaskIntoConstraints: A view (esp a designable one) should never set its own value for this property.

    Interface Builder (or the view controller, if doing it programmatically) is responsible for configuring this. A view can set translatesAutoresizingMask and constraints for its subviews (if it had any, which you do not in this case) if it is using auto layout for subviews, but a view should never set its own translatesAutoresizingMaskIntoConstraints (nor add its own width/height constraints).

    See https://github.com/csarkis/TestIBDesignablesPod/pull/1.


    A few other suggestions:

    1. Your draw(_:) is using supplied CGRect to determine the size and placement of the radio button.

      Do not do that. Always use bounds for computing where things go, not the supplied rect parameter. As the documentation says, the rect is

      The portion of the view’s bounds that needs to be updated. The first time your view is drawn, this rectangle is typically the entire visible bounds of your view. However, during subsequent drawing operations, the rectangle may specify only part of your view.

      So, in complicated views, we might use rect to determine what is drawn (e.g. if rect doesn't intersect with what you're drawing, you do not need to draw it). But never use rect to determine where it is drawn. Use bounds.

    2. In a more radical edit, you have a setupViews for your @IBDesignable view:

      /// Setup the view initially
      private func setupViews() {
          self.subviews.forEach { $0.removeFromSuperview() }
          self.translatesAutoresizingMaskIntoConstraints = false
          self.autoresizesSubviews = false
      
          self.heightAnchor.constraint(equalToConstant: 40).isActive = true
          self.widthAnchor.constraint(equalToConstant: 40).isActive = true
      
          prepareForInterfaceBuilder()
          setNeedsDisplay()
      }
      

      You should not be doing any of that in this designable view:

      • You have no subviews, so iterating through and deleting all subviews is unnecessary (and that is an extremely imprudent practice, btw, as you do not know what subviews someone else may have added). Nor is autoresizesSubviews necessary.

      • As described above, a view (esp a designable one) should never set its own translatesAutoresizingMaskIntoConstraints. That is the job of whatever added the view to the view hierarchy (IB in this case). Nor should you set the constraints.

      • You are calling prepareForInterfaceBuilder and setNeedsDisplay here. Neither is needed nor desired.

      Now that you no longer have setupSubviews, a whole bunch of other observers and overrides are now no longer necessary, greatly simplifying the code. See https://github.com/csarkis/TestIBDesignablesPod/pull/2.