PHBStackLayout 1.0.2

PHBStackLayout 1.0.2

Maintained by Phlippie Bosman.



  • By
  • phlippieb

PHBStackLayout

Version License Platform

What is this?

tl;dr -- This is a μFramework that provides declarative syntax for UIStackView-based layouts in Swift.

Background -- UIStackView layout

UIStackViews facilitate some surprisingly powerful layout-building. For example, say I want to create a layout for a table view cell with the following components:

  • An image on the left, centered vertically
  • A heading to the right of that image, aligned to the top of the cell
  • A subheading underneath that heading
  • A chevron icon on the right of the cell

I could lay this out using a lot of thing.anchor.constraint(equalTo: otherThing.anchor).isActive = true-statements. But, depending on how much you've worked with constraint-based autolayout, such statements don't always read as clearly as they could -- especially when you're not just laying components out in a single direction.

In my opinion, the intent of the layout becomes more clear if I do it as follows:

  • Create a horizontal UIStackView to contain, from left to right, the image, text, and chevron
  • Create a vertical UIStackView to contain, from top to bottom, the heading and the subheading

Such an implementation is more in line with an intuitive understanding of the layout, which consists of a main left-to-right layout, with a nested top-to-bottom sub-layout. This implementation creates an adaptive, AutoLayout-backed UI, without the need for any constraints.

As an added bonus, stack views handle hidden subviews much better than constraint-based layouts. If an arranged subview of a stack view gets hidden, the entire layout adapts as if the view isn't in the layout at all. For example, in my table view cell above, if I don't want my cell to have a chevron icon in some cases, I can simply set the label or image view's isHidden property to true, and the text views will now fill up the available space all the way to the right of the cell.

The declarative syntax wrapper

The one problem with UIStackView layouts is the tedium of creating, configuring and maintaining all those stack views. I wanted to create a wrapper that handles all that boilerplate code for me. Such a wrapper also presents an opportunity to expose some extra functionality that may come in handy when creating UIs, such as adding spacing between elements, or adding insets around elements. And since we're doing away with constraints, the wrapper can also give us a convenient way of installing a layout in a parent view.

So that's what this is: a framework that wraps UIStackView-based layouts in a convenient and powerful syntax.

What about SwiftUI?

SwiftUI is Apple's shiny new declarative UI paradigm. By all accounts, it's going to solve the problem of tedious layout code for good. PHBStackLayout pales in comparison. So why did I make it? Simply because I need something to help me with UI code now, and

  • SwiftUI isn't ready yet
  • I need to support versions of iOS that will not be supported by SwiftUI when it does get released

Usage

Example

Before diving into the details, let's take a look at an example.

Given some subviews (an image view, a heading label, and a subheading label), consider the following layout code in a UITableViewCell subclass:

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
   super.init(style: style, reuseIdentifier: reuseIdentifier)
   
   let layout = StackLayout.inset(by: 8, direction: .vertical, of:
      .inset(by: 9, direction: .horizontal, of:
         .cols(alignment: .center, of: [
            .view(self.imageView),
            .spacing(of: 12: direction: .horizontal),
            .rows(alignment: .left, of: [
               .view(self.headingLabel),
               .spacing(of: 4, direction: .vertical),
               .view(self.subheadingLabel) ])])))

   layout.install(in: self.contentView)
}

This creates and installs a layout where:

  • All content is inset by 8 points from the top and bottom, and by 9 from the left and right
  • The image view is at the left, centered vertically
  • The heading label is to the right of the image view, offset by 12 points, at the top of the cell
  • The subheading label is 4 points below the heading label, also offset by 12 points from the image view, at the bottom of the cell

Note how the layout is structured as a tree, and how the level of sub-layouts is indicated by indentation in the code.

The StackLayout tree

A stack layout is a tree, where each node of the tree is a StackLayout object.

The StackLayout struct provides 5 static methods to create such nodes:

  • view
  • spacing
  • inset
  • rows
  • cols

Rather than thinking of these as methods, though, they can be thought of as types of nodes. Some of them contain sub-nodes, and others act as leaf nodes.

Ultimately, each node is associated with a UIView. For parent nodes, that view contains the sub-views that are associated with its child nodes. For leaf nodes, that view has no sub-views. Any node in a layout tree can be installed in a view (such as the root view of a view controller) by accessing its view.

Layout nodes

The types of nodes are:

view

A view node is a leaf node that represents a single view. It is created by providing the UIView-subclass that you want the node to represent.

For example, to create a layout node for a single label:

let layout = StackLayout.view(UILabel())

rows

A rows node is a parent node that arranges its child nodes in a vertical stack. It is created by providing the child nodes to be arranged in a stack, and optionally specifying their horizontal alignment.

For example, to create a layout of two rows of labels:

let layout = StackLayout.rows(of: [
   .view(UILabel()), // First row
   .view(UILabel()) // Second row
])

Under the hood, the rows method creates a UIStackView1, configures its axis and alignment, and then adds the associated view of each of the given child nodes as an arranged subview of the stack view.

cols

A cols node is functionally equivalent to a rows node, except that a cols node arranges its child nodes horizontally. It is created by providing the child nodes to be arranged in a stack, and optionally specifying their vertical alignment.

For example, to create a layout of two columns of layouts:

let layout = StackLayout.cols(of: [
   .view(UILabel()), // First column
   .view(UILabel()) // Second column
])

Similarly to the rows method, the cols method creates and configures a UIStackView1 under the hood.

spacing

A spacing node is a leaf node that represents empty space of a specific dimension in a specific direction. It is created by specifying the dimension and direction. A spacing node is used to create padding between or around other elements in a layout.

For example, to create a layout of two rows of labels with 10 points of spacing between them:

let layout = StackLayout.rows(of: [
   .view(UILabel()), // First row
   .spacing(of: 10, direction: .vertical), // 10 points of spacing
   .view(UILabel()) // Second row
])

Under the hood, the spacing method creates a UIView1 and activates a width or height constraint on it.

inset

An inset node is a parent node that adds a fixed inset on a single axis on either side of its single child node. It is created by specifying the dimension and direction of the inset, as well as the child node.

For example, to create a layout of a single label with 10 points of inset to its left and right:

let layout = StackLayout.inset(
   by: 10,
   direction: .horizontal,
   of: .view(UILabel()))

The inset method is actually a convenience method to create a stack view containing three views: the view of the given child node in the middle, and two spacer views around it. As such, under the hood, it creates a rows or cols node with a spacing, view, and another spacing node as its child nodes.

Some technical considerations

isUserInteractionEnabled vs hitTest

Some real-world situations have tricky requirements in terms of interaction.

For example, consider a card-like cell. When a user taps on the card, the app should navigate somewhere. The card has some subviews (images, labels, etc) that should not interfere with the interactability of the card on the whole. But the card also has a button at the bottom which, when tapped, should dismiss the card.

In this a case, it is tempting but dangerous to set isUserInteractionEnabled on the container views (such as the stack view used to do the layout). It might seem like that would make all the card's subviews non-interactable by default and prevent the subviews from intercepting taps that should go to the card. But in actual fact, the subviews would not just become non-interactable by default; there would be no way to make the button interactable.

The actual solution is to subclass the container view and override hitTest to pass the interactions through to the parent class.

Therefore, in this framework's implementation, the nodes don't actually use UIView for spacing, nor UIStackView for the stacks. Instead, custom non-interactable subclasses of those classes are used. See NonInteractableViews.swift.

Installation

Cocoapods

PHBStackLayout is available through CocoaPods. To install it, simply add the following line to your Podfile:

pod 'PHBStackLayout'

To run the example project, clone the repo, and run pod install from the Example directory first.

Author

phlippieb, [email protected]

License

PHBStackLayout is available under the MIT license. See the LICENSE file for more info.

Footnotes

1: This framework actually uses subclasses of UIView and UIStackView; see isUserInteractionEnabled vs hitTest