Custom UIView in Swift done right

Main setup

This is the basic setup for a custom view (we will continue with an in-code view setup because xib-based approach is easier & Interface Builder hides many UIKit boilerplate that an experienced iOS developer should know)

class CustomView: UIView {
//initWithFrame to init view from code
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}

//initWithCode to init view from xib or storyboard
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}

//common func to init our view
private func setupView() {
backgroundColor = .red
}
}

//in playground we should have a red rectangle
let view = CustomView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))

Setup UIKit views

In this section we will setup UIKit views which will contain out custom view. In this step we set frames manually just for the demonstration purpose.

class CustomView: UIView {
//we use lazy properties for each view
lazy var addButton: UIButton = {
let addButton = UIButton(type: .contactAdd)
addButton.frame = CGRect(x: 265, y: 5, width: 30, height: 30)
return addButton
}()

lazy var contentView: UIImageView = {
let contentView = UIImageView(frame: CGRect(x: 0, y: 40, width: 300, height: 260))
contentView.image = UIImage(named: "WoodTexture")
return contentView
}()

lazy var headerTitle: UILabel = {
let headerTitle = UILabel(frame: CGRect(x: 0, y: 0, width: 300, height: 40))
headerTitle.font = UIFont.systemFont(ofSize: 22, weight: .medium)
headerTitle.text = "Custom View"
headerTitle.textAlignment = .center
return headerTitle
}()

lazy var headerView: UIView = {
let headerView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 40))
headerView.backgroundColor = UIColor(red: 22/255, green: 160/255, blue: 133/255, alpha: 0.5)
headerView.layer.shadowColor = UIColor.gray.cgColor
headerView.layer.shadowOffset = CGSize(width: 0, height: 10)
headerView.layer.shadowOpacity = 1
headerView.layer.shadowRadius = 5
headerView.addSubview(headerTitle)
headerView.addSubview(addButton)
return headerView
}()

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

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

private func setupView() {
backgroundColor = .white
addSubview(contentView)
addSubview(headerView)
}
}

Introduce AutoLayout instead of plain frames

The view remains the same, but we have several problems.

  • Where to put layout code
  • How to define proper content size instead of hardcoded 300 width and height

Where to put layout code

There is a misunderstanding that you should setup initial constraints in updateConstraints method. A boolean flag is used inside a view, but this is a code smell rather than usage of this function as it was intended.

override func updateConstraints() {
if !didSetConstraints {
didSetConstraints = true
//setup layout
}

//update constraints
super.updateConstraints()
}
class CustomView: UIView {
lazy var addButton: UIButton = {
let addButton = UIButton(type: .contactAdd)
addButton.translatesAutoresizingMaskIntoConstraints = false
return addButton
}()

lazy var contentView: UIImageView = {
let contentView = UIImageView()
contentView.image = UIImage(named: "WoodTexture")
contentView.translatesAutoresizingMaskIntoConstraints = false
return contentView
}()

lazy var headerTitle: UILabel = {
let headerTitle = UILabel()
headerTitle.font = UIFont.systemFont(ofSize: 22, weight: .medium)
headerTitle.text = "Custom View"
headerTitle.textAlignment = .center
headerTitle.translatesAutoresizingMaskIntoConstraints = false
return headerTitle
}()

lazy var headerView: UIView = {
let headerView = UIView()
headerView.backgroundColor = UIColor(red: 22/255, green: 160/255, blue: 133/255, alpha: 0.5)
headerView.layer.shadowColor = UIColor.gray.cgColor
headerView.layer.shadowOffset = CGSize(width: 0, height: 10)
headerView.layer.shadowOpacity = 1
headerView.layer.shadowRadius = 5
headerView.addSubview(headerTitle)
headerView.addSubview(addButton)
headerView.translatesAutoresizingMaskIntoConstraints = false
return headerView
}()

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

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

private func setupView() {
backgroundColor = .white
addSubview(contentView)
addSubview(headerView)
setupLayout()
}

private func setupLayout() {
NSLayoutConstraint.activate([
//pin headerTitle to headerView
headerTitle.topAnchor.constraint(equalTo: headerView.topAnchor),
headerTitle.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
headerTitle.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
headerTitle.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),

//layout addButton in headerView
addButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
addButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -10),

//pin headerView to top
headerView.topAnchor.constraint(equalTo: topAnchor),
headerView.leadingAnchor.constraint(equalTo: leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 40),

//layout contentView
contentView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}

//custom views should override this to return true if
//they cannot layout correctly using autoresizing.
//from apple docs https://developer.apple.com/documentation/uikit/uiview/1622549-requiresconstraintbasedlayout
override class var requiresConstraintBasedLayout: Bool {
return true
}
}

How to define proper content size

If we define width or height implicit inside out view and someone wants to add another constraints to it’s size we get conflicting constraints error. Do not define x, y, width or height inside a custom view. That is the responsibility of the superview. Instead, use a property called intrinsicContentSize. In our custom view the intrinsic size is being calculated from constraints to contentView, which is a UIImageView, but if our content size depends on some internal criteria use intrinsicContentSize.

override var intrinsicContentSize: CGSize {
//preferred content size, calculate it if some internal state changes
return CGSize(width: 300, height: 300)
}
lazy var contentView: UIImageView = {
...
contentView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
contentView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
}()

Add some actions

For example, we need to adjust the position of the header by 10 when the + button was pressed.

private func setupView() {
...
setupActions()
}

//we add a top constraint property
private var headerViewTop: NSLayoutConstraint!

private func setupLayout() {
headerViewTop = headerView.topAnchor.constraint(equalTo: topAnchor)
...
}

private func setupActions() {
addButton.addTarget(self, action: #selector(moveHeaderView), for: .touchUpInside)
}

@objc private func moveHeaderView() {
//here we have 2 ways to modify the constraint

//first one (easier & preffered)
//manual trigger layout cycle
headerViewTop.constant += 10
setNeedsLayout()

//second one (use for performance boost)
headerViewTopConstant += 10
}

//introduce a new variable for updateConstraints logic
private var headerViewTopConstant: CGFloat = 0 {
didSet {
//manual trigger layout cycle, but here
//we will set the constant inside updateConstraints
setNeedsUpdateConstraints()
}
}

override func updateConstraints() {
headerViewTop.constant = headerViewTopConstant
super.updateConstraints()
}

General rules

  • Do not add constraints to superview (it is the responsibility of the superview)
  • Frames do not change after constaint constant change, a full layout cycle should happen.
  • Add constraints in initializer, modify them in place or in updateConstraints for performance.
  • To define size use intrinsicContentSize.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store