Interface Builder Integration with Custom Controls

Interface Builder Integration with Custom Controls

Make a custom control Interface Builder compliant using IBDesignable and IBInspectable

Go to the profile of  Jonathan Banks
Jonathan Banks
10 min read
Time
45mins
Platforms
iOS
Difficulty
Easy
Technologies
Swift, Interface Builder

This tutorial demonstrates the conversion of a manually designed class into one that is adjustable from Interface Builder. Download the existing project code, apply the changes detailed here, and wire it directly into Interface Builder.

Screen-Shot-2019-07-17-at-3.56.29-PM

Before proceeding, make sure to glance through the previous tutorial here and understand, at a high level, the steps required to create that custom control.

Background

Take a brief look at the purpose of Interface Builder, from Wikipedia:

Interface Builder saves an application's interface as a bundle that contains the interface objects and relationships used in the application. These objects are archived (a process also known as serialization or marshalling in other contexts) into either an XML file or a NeXT-style property list file with a .nib extension.

Upon running an application, the proper NIB objects are unarchived, connected into the binary of their owning application, and awakened...NIBs are often referred to as freeze dried because they contain the archived objects themselves, ready to run.

Interface Builder serializes the state of the control then deserializes it each time the storyboard becomes visible. In an application on a device, the same thing happens, but the runtime performs the serialization/deserialization.

Interface Builder, XCode 10

The overarching purpose of Interface Builder is to allow the quick mock-up of working interfaces using standardized tools and controls. Drop various controls into Interface Builder and then drag directly into code to connect Actions and Outlets to them.

Advantages & Disadvantages of Interface Builder

There are several huge advantages when code is wired into Interface Builder:

  1. Reduction of boilerplate code
  2. Faster tweaking, real-time rendering, and customization of the user interface. No code changes required.

Consider the disadvantages before jumping head-first into Interface Builder integration. Here are a few of those to keep in mind:

  1. Won't work for large teams: resolving Interface Builder merge conflicts on large projects is a near-impossible task.
  2. Code Reuse/Duplicating Existing Work: copying a xib or storyboard from one project to another does not work. Cut and paste pure code into a new project and it will immediately run with minimum fuss.
  3. XCode Slowdown: as of mid-2019 this is still an issue with XCode. Attach an IBDesignable and IBInspectable keyword to a class and XCode will rebuild the storyboard every time the code is modified.
  4. Debugging is Difficult: in general debugging Interface Builder is not a problem. When bugs do arise they are challenging to track down due to the black-box nature of Interface Builder.

Pros and cons lists are helpful but is there a programming paradigm to follow?

Look no further than this common saying in programming circles, "the easiest code to maintain is no code at all." That statement summarizes, in a nutshell, the biggest virtue of Interface Builder. Given the appropriate situation, Interface Builder can help reduce the amount of code that needs maintenance.

Setup

Download the final code for this tutorial here and the previous tutorial here.

The recommendation is to download both. Use the finished version as a guide while following along with the step-by-step code from this article. The goal is to integrate the new Interface Builder settings to the previous project code.

@IBDesignable

Switch on Interface Builder integration by adding the @IBDesignable attribute before the class keyword.

@IBDesignable class SelectorTextField: UIView {

The attribute tells the Interface Builder engine that it can access the assigned code and render it on the storyboard canvas. With these hooks installed, Interface Builder can pull the code into its layout engine and position it on the storyboard.

@IBInspectable

The next step is to identify the targeted class variables and expose them as user-definable in Interface Builder. There are a limited number of data types that can be used in Interface Builder. Refer to the list below:

  • CGFloat
  • Double
  • Int
  • String
  • Bool
  • UIColor
  • UIImage
  • CGPoint
  • CGSize
  • CGRect

You are not permitted to use complex data types and data structures in Interface Builder. That means enums, structs, arrays, and dictionaries, are not allowed.

At a high level, most controls will only have a few tweakable elements. Common elements are:

  • Color
  • Size
  • Shape
  • Text

There are a large number of attributes that can be exposed to Interface Builder. For example, a custom control could expose textColor, border, border radius, background color, etc.

Take a look at the SelectorTextField:
Screen-Shot-2019-07-17-at-7.34.16-PM
Based on the look of the control and to keep tight focus, only the following attributes will be exposed in the SelectorTextField:

  • Color: divider, arrow, text, background
  • Size: text, divider
  • Text: the placeholder text "Select a sport"

@IBInspectable in Code

The targeted variables need conversion to @IBInspectable. Taking the arrowColor as a concrete example:

@IBInspectable var arrowColor:UIColor = UIColor.black{
        
    didSet{
        dropDownArrow.tintColor = arrowColor
    }
}

The process to enable @IBInspectable is as follows:

  1. Add the @IBInspectable keyword in front of the var keyword.
  2. Customize the didSet Property Observer to change the targeted control attribute.

After these are complete, the user adjusts the arrowColor from the storyboard Attributes Inspector panel. Refer below:

Screen-Shot-2019-07-17-at-9.46.00-PM

Now apply the @IBInspectable keyword and override didSet for the remaining targeted variables.

@IBInspectable var initialText:String = "Select a Sport"{
    didSet {
        textField.text = initialText
    }
}
    
@IBInspectable var selectorTextColor:UIColor = UIColor.black{

    didSet{
        textField.textColor = selectorTextColor

    }
}
    
@IBInspectable var selectorFontSize:CGFloat = 18.0{

    didSet {
        self.textField.font = UIFont.systemFont(ofSize: selectorFontSize, weight: UIFont.Weight.medium)
    }
}

The didSet closure can execute any number of attribute changes. Refer to the code for selectorBackgroundColor:

@IBInspectable var selectorBackgroundColor:UIColor = UIColor.init(hexString: "ebedf0"){

    didSet{
        containerView.backgroundColor = selectorBackgroundColor
        tableView.backgroundColor = selectorBackgroundColor
    }
}

Modifying selectorBackgroundColor changes both the tableView and its parent containerView background colors.

The dividerEnabled and dividerSize variables apply even more complex logic to attribute adjustment. Refer to the code below:

    
@IBInspectable var dividerEnabled:Bool = true{

    didSet {
        if dividerEnabled{
            containerView.layoutIfNeeded()
            borderLayer = containerView.layer.addBorder(edge: .bottom, color: dividerColor, thickness: dividerSize)
        }else{
            borderLayer.removeFromSuperlayer()
            containerView.setNeedsLayout()
        }
    }
}

```swift
    
@IBInspectable var dividerSize:CGFloat = 3.0{

    didSet {
        if dividerEnabled{
            borderLayer.removeFromSuperlayer()
            containerView.layoutIfNeeded()
            borderLayer = containerView.layer.addBorder(edge: .bottom, color: dividerColor, thickness: dividerSize)
            containerView.setNeedsLayout()
        }

    }
}

Enabling the divider results in the code adding a border CALayer to the containerView. Disabling the divider removes the border CALayer from the superLayer.

Call the layoutIfNeeded method prior to adding new layers. The border will not display properly after making changes then calling setNeedsLayout.

Finally, modify the didSet method of dividerColor:

@IBInspectable var dividerColor:UIColor = UIColor.darkGray{

    didSet {

        if dividerEnabled{
            borderLayer.backgroundColor = dividerColor.cgColor
            containerView.setNeedsLayout()
        }
    }
}

Summary of @IBInspectable

There are two key takeaways from the examples shown thus far:

  1. Build complex logic into the didSet portion of the @IBInspectable attribute.
  2. Track each CALayer added to a control. Remove the tracked CALayers from a control per logic in the didSet method. Neglecting to track the layers will result in memory leaks in the XCode storyboard and in production on devices.

Storyboard

Drag a new UIView onto the UIViewController in Storyboard. Select it and then tap on the Identity Inspector on the sidebar. Set the value to SelectorTextField for the custom class name.

Screen-Shot-2019-07-17-at-11.09.54-PM

The height of the UIView expands immediately to the calculated height. Notice that the dropDownArrow as well as the "Select a sport" text is visible.

Screen-Shot-2019-07-18-at-1.30.01-PM-1

The custom control is now accessible from the storyboard.

Position the SelectorTextField by applying the appropriate constraints for leading, trailing, top, and bottom anchors. Set all of them except for the height constraint. The code added for the control sets the height programmatically by assigning a calculated frame. The control is then animated on-demand.

Select the newly created SelectorTextField in Storyboard. You can view the previously created variables in the Attributes Inspector and adjust their values.

Screen-Shot-2019-07-17-at-11.02.52-PM

There are two key takeaways based on what's visible in the Attributes Inspector:

  1. Camel-cased variables get split at the "hump" into titles.
  2. Grouped variables with a common prefix result in sections.

Variable naming and position in code are important considerations for logical layout in storyboard.

Functions and Overridden Variables

IntrinsicContentSize

How does Interface Builder know how to render the size of the custom control? The overriden variable intrinsicContentSize provides Interface Builder with the calculated size:

override var intrinsicContentSize: CGSize {
    return CGSize.init(width: self.bounds.width, height: self.textFieldHeight + (CGFloat(tableViewData.count) * cellHeight))
    }
    

The UIViewController provides the width to the control externally. The number of elements in the variable tableViewData determines the height of the control.

From Apple's documentation on intrinsicContentSize:

Custom views typically have content that they display of which the layout system is unaware. Setting this property allows a custom view to communicate to the layout system what size it would like to be based on its content. This intrinsic size must be independent of the content frame, because there’s no way to dynamically communicate a changed width to the layout system based on a changed height, for example.

Based on Apple's guidance, it is important to override intrinsicContentSize on custom controls so the Interface Builder engine and runtime process on a device have specific layout instructions.

init(coder aDecoder: NSCoder)

The SelectorTextField currently sets the frame dimensions in code using autolayout constraints. Refer to the code for the initializer without Interface Builder integration:

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

    self.bounds.size = CGSize(width: self.frame.width, height: self.bounds.height + (CGFloat(tableViewData.count) * cellHeight))

    initializeComponents()
    positionComponents()

    textViewGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    textField.addGestureRecognizer(textViewGesture)
    dropDownGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    dropDownArrow.addGestureRecognizer(dropDownGesture)

    status = Status.closed

}

Implemention of the init?(coder aDecoder: NSCoder) initializer is a requirement to use the control in Interface Builder. Do this by taking advantage of the code previously written for the standard initializer.

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

    self.bounds.size = CGSize(width: self.frame.width, height: self.bounds.height + (CGFloat(tableViewData.count) * cellHeight))

    initializeComponents()
    positionComponents()

    textViewGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    textField.addGestureRecognizer(textViewGesture)
    dropDownGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    dropDownArrow.addGestureRecognizer(dropDownGesture)

    status = Status.closed
        
    }

Remember that the NSCoder version is what's called by Interface Builder to reconstruct the deserialized version of the control into a component visible in the interface.

The NSCoder protocol has two functions for serializing and deserializing:

encodeWithCoder(_ aCoder: NSCoder) {
   
}

init(coder aDecoder: NSCoder) {
    
}

The init function has the same format as the init in the SelectorTextField class. That's the clue that Interface Builder is deserializing the SelectorTextField for rendering in the storyboard.

The SelectorTextField has no complex types, so serializing and deserializing requires no additional code. Complex controls with sets of a custom types will require additional work to prepare for the serializing process.

The project code instantiates one SelectorTextField programmatically and one via Interface Builder. Place a breakpoint or print statement in each of the constructors and run the project again. Notice there is one method call for each initializer.

  • init(frame: CGRect) programmatically instantiated version.
  • init(coder aDecoder: NSCoder) Interface Builder version.

prepareForInterfaceBuilder

The prepareForInterfaceBuilder is the final method to override to complete Interface Builder integration. This method performs the final graphical adjustments to prepare your custom control for display in the Interface Builder. From the Apple documentation:

When Interface Builder instantiates a class with the IB_DESIGNABLE attribute, it calls this method to let the resulting object know that it was created at design time. You can implement this method in your designable classes and use it to configure their design-time appearance. For example, you might use the method to configure custom text controls with a default string. The system does not call this method; only Interface Builder calls it.

The prepareForInterfaceBuilder method adjusts the display of the target control and compensates for any Interface Builder-specific rendering issues. The application does not call the method when running on an actual device or simulator.

Refer to the code for prepareForInterfaceBuilder:

override func prepareForInterfaceBuilder() {
        
    super.prepareForInterfaceBuilder()

    textField.contentVerticalAlignment = .fill

    if selectorFontSize <= 10{
        textFieldTopConstraint.constant = 13.0
    }else if selectorFontSize > 10 && selectorFontSize <= 15{
        textFieldTopConstraint.constant = 10.0
    }else if selectorFontSize > 15 && selectorFontSize <= 20{
        textFieldTopConstraint.constant = 7.0
    }else if selectorFontSize > 20 && selectorFontSize <= 30{
        textFieldTopConstraint.constant = 5.0
    }

    let dynamicBundle = Bundle(for: type(of: self))
    let img = UIImage(named: "dropdown", in: dynamicBundle, compatibleWith: nil)
    dropDownArrow.image = img

    if dividerEnabled{
        containerView.layoutIfNeeded()
        borderLayer = containerView.layer.addBorder(edge: .bottom, color: dividerColor, thickness: dividerSize)
        containerView.setNeedsLayout()
    }
}

Breaking down that method code, this section of code corrects an Interface Builder textField display issue:

textField.contentVerticalAlignment = .fill
if selectorFontSize <= 10{
        textFieldTopConstraint.constant = 13.0
    }else if selectorFontSize > 10 && selectorFontSize <= 15{
        textFieldTopConstraint.constant = 10.0
    }else if selectorFontSize > 15 && selectorFontSize <= 20{
        textFieldTopConstraint.constant = 7.0
    }else if selectorFontSize > 20 && selectorFontSize <= 30{
        textFieldTopConstraint.constant = 5.0
}

The textField does not center text within the containerView when it's on display in Interface Builder. The code compensates for the problem by setting the contentVerticalAlignment attribute to .fill and placing a topAnchor constraint on the textField. Based on the size of the font, the topAnchor constraint is changed to keep the text centered in Interface Builder.

Correct the drop-down arrow display issue with this code:

 let dynamicBundle = Bundle(for: type(of: self))
 let img = UIImage(named: "dropdown", in: dynamicBundle, compatibleWith: nil)

The embedded image for the dropDownArrow was not rendering based on the name alone. Directly accessing the Bundle copy of the image resolved this issue.

Finally, make sure changes are applied with this section of code:

if dividerEnabled{
    containerView.layoutIfNeeded()
    borderLayer = containerView.layer.addBorder(edge: .bottom, color: dividerColor, thickness: dividerSize)
    containerView.setNeedsLayout()
    }

If the divider is enabled, this code forces the immediate layout of the container with containerView.layoutIfNeeded() and adds the border manually. The second call to setNeedsLayout() requests another refresh on the next update cycle after the border change.

Final Thoughts

The SelectorTextField code integrating with Interface Builder is finally complete. Designers can use Interface Builder to fine-tune the design of the SelectorTextField over a range of settings. Those design changes are now immediately reflected on change directly in the display.

Screen-Shot-2019-07-18-at-1.38.36-PM

The benefits may not be obvious for this simple project with one control. Imagine a much larger project with many custom controls spread across a large set of UIViewControllers. Tweaking the look and feel of one control will ripple across many screens and could result in clashes with other controls.

Seeing immediate changes is a more desirable feature from the designer's perspective. If the look and feel of the application are in-flux it's even more critical. Make sure to weigh the disadvantages mentioned in this article against the speed gains and utility and decide the right path for the project.