Skip to content

EpoxyLayoutGroups

Tyler Hedrick edited this page May 17, 2021 · 2 revisions

EpoxyLayoutGroups

LayoutGroups are UIKit Auto Layout containers inspired by SwiftUI's HStack and VStack that allow you to easily compose UIKit elements into horizontal and vertical groups.

Below are a few sample components from the Airbnb app that we've built using LayoutGroups. We have over 70 components built using LayoutGroups.

Example components built with LayoutGroups

Overview

LayoutGroups follow the same design patterns as the rest of Epoxy by providing a declarative API for composing elements into a single view. Whereas EpoxyCollectionView allows you to declaratively specify what components you'd like on a given screen, LayoutGroups allows you to declaratively specify what elements create each of those components.

VGroup allows you to group components together vertically to create stacked components like this:

ActionRow
ActionRow screenshot
// Set of dataIDs to have consistent and unique IDs
enum DataID {
  case title
  case subtitle
  case action
}

// Groups are created declaratively just like Epoxy ItemModels
let group = VGroup(alignment: .leading, spacing: 8) {
  Label.groupItem(
    dataID: DataID.title,
    content: "Title text",
    style: .title)
  Label.groupItem(
    dataID: DataID.subtitle,
    content: "Subtitle text",
    style: .subtitle)
  Button.groupItem(
    dataID: DataID.action,
    content: "Perform action",
    behaviors: .init { button in
      print("Button tapped! \(button)")
    },
    style: .standard)
}

// install your group in a view
group.install(in: view)

// constrain the group like you would a normal subview
group.constrainToMargins()

As you can see, this is incredibly similar to the other APIs used in Epoxy. One important thing to note is that install(in: view) call at the bottom. Both HGroup and VGroup are written using UILayoutGuide which prevents having large nested view hierarchies. To account for this, we’ve added this install method to prevent the user from having to add subviews and the layout guide manually.

Using HGroup is almost exactly the same as VGroup but the components are now horizontally laid out instead of vertically:

IconRow
IconRow screenshot
enum DataID {
  case icon
  case title
}

let group = HGroup(spacing: 8) {
  ImageView.groupItem(
    dataID: DataID.icon,
    content: UIImage(systemName: "person.fill")!,
    style: .init(size: .init(width: 24, height: 24)))
  Label.groupItem(
    dataID: DataID.title,
    content: "This is an IconRow")
}

group.install(in: view)
group.constrainToMargins()

Documentation

Here’s a high level interface for a group:

public final class {H|V}Group: UILayoutGuide, Constrainable {
  /// must be called once set up to install this group in the view hierarchy
  public func install(in view: UIView)

  /// Replace the current items with a new set of items.
  /// This does an ordered collection diff to only replace items needed
  /// and then redoes all of the constraints.
  /// This method does nothing if the array of new items is identical
  /// to the existing set of items
  func setItems(_ newItems: [GroupItemModeling?])
}

HGroup has a few unique properties:

extension HGroup {
  /// prevents reflow of elements at accessibility type sizes
  public func reflowsForAccessibilityTypeSizes(_ reflows: Bool) -> HGroup

  /// Forces HGroup to be in a vertical layout
  /// Can be useful when you want to change layouts without installing a new group
  public func forceAccessibilityVerticalLayout(_ forceIn: Bool) -> HGroup
}

GroupItem and GroupItemModeling

Each group accepts an array of GroupItemModeling conforming types that it uses to perform intelligent diffs and lazily instantiate subviews. There are a number of provided types that confrom to GroupItemModeling each with their own purpose:

Type Description
GroupItem<ItemType> A generic item that can be used with any ItemType that conforms to EpoxyableView. This item can be created more easily using the helpers provided in StyledView+GroupItem.swift
HGroupItem An item that represents an HGroup that should be used when nesting HGroups in a parent group
VGroupItem An item that represents a VGroup that should be used when nesting VGroups in a parent group
SpacerItem An item that represents a Spacer
StaticGroupItem An item that represents a static Constrainable. You can use this if you have a subview that has been instantiated already or if you do not want to use the automatic diffing algorithm provided by LayoutGroups

Composing groups

Here comes the fun part: HGroup and VGroup don't only accept views, but they can also accept other groups! This allows you to easily compose many groups together to get the layout you need. It's imporatnt to note that when nesting groups you should use the item version of each group HGroupItem for HGroup and VGroupItem for VGroup. For example, if I wanted to create a simple todo app with a checkbox row:

CheckboxRow
CheckboxRow

I could do it easily by combining an HGroup with a VGroupItem like this:

enum DataID {
  case checkbox
  case titleSubtitleGroup
  case title
  case subtitle
}

HGroup(spacing: 8) {
  Checkbox.groupItem(
    dataID: DataID.checkbox,
    content: .init(isChecked: true),
    style: .standard)
  VGroupItem(
    dataID: DataID.titleSubtitleGroup, 
    style: .init(spacing: 4)) 
  {
    Label.groupItem(
      dataID: DataID.title,
      content: "Title",
      style: .title)
    Label.groupItem(
      dataID: DataID.subtitle,
      content: "Subtitle",
      style: .subtitle)
  }
}

When you call install(in: view) on the outer HGroup it will recursively install each sub group in the view. This flattens the view hierarchy so that everything has a common ancestor and uses a set of UILayoutGuides to do the layout.

Spacing

Spacing between the two elements of a group can be manually set:

VGroup(spacing: 16) {
  Label.groupItem(...)
  Label.groupItem(...)
}

Spacer is a very simple component that allows you to add space between groups or elements in a group. Spacer acts just like any other element, but doesn’t render anything. The default Spacer will fill up as much space as it can, which allows you to move elements apart easily between groups. The follow example shows how you can use a spacer between elements in an HGroup to push them to the leading and trailing edges:

HGroup {
  // name
  Label.groupItem(...)
  // message status icon
  GroupItem<UIView>(...)
  // spacer
  SpacerItem(dataID: DataID.spacer)
  // date label
  Label.groupItem(...)
  // disclosure indicator
  ImageView.groupItem(...)  
}

Without the spacer in the middle, the dateLabel would likely be right next to the messageStatusIcon when we actually want it pushed to the trailing side. I say "likely" here because it depends on what alignment you use in each of these subviews, or the HGroup itself. On top of that, things like contentCompressionResistence and contentHuggingPriority will also take effect here. Having an understanding of how AutoLayout works will be very helpful in helping solve layout problems like this one.

You can also specify it’s size explicitly to update the spacing between elements with more fine-tuned control.

HGroup {
  ...
  SpacerItem(dataID: DataID.spacer, style: .init(minWidth: 50))
  ...
}

In this example, the messageStatusIcon will always be at least 50 points away from the dateLabel, but will otherwise be as far to the trailing side as it can be.

Spacer's style can be initialized with any of the following values:

Spacer values Description
minHeight the Spacer will take up at least minHeight pixels in the vertical direction
maxHeight the Spacer will take up at most maxHeight pixels in the vertical direction
fixedHeight the Spacer will take up exactly fixedHeight pixels in the vertical direction
minWidth the Spacer will take up at least minWidth pixels in the horizontal direction
maxWidth the Spacer will take up at most maxWidth pixels in the horizontal direction
fixedWidth the Spacer will take up exactly fixedWidth pixels in the horizontal direction

StaticGroupItem

If you have a simple layout that isn't going to update, you might not want to go through the trouble of creating a group item for each of these subviews. In cases like this, you can use a StaticGroupItem to represent an already instantiated Constrainable:

let titleLabel = UILabel(...)
let subtitleLabel = UILabel(...)

let group = VGroup(spacing: 8) {
  StaticGroupItem(titleLabel)
  StaticGroupItem(subtitleLabel)
}

group.install(in: self)
group.constrainToMargins()

GroupItem without EpoxyableView

While we think having all of your components conform to EpoxyableView for consistency, it is not required to use a component inside of a Group. When your component do conform to EpoxyableView you can use the helper functions in StyledView+GroupItem like this:

MyComponent.groupItem(
  dataID: ...,
  content: ...,
  behaviors: ...,
  style: ...)

However, if you have a component that doesn't conform to EpoxyableView you can use GroupItem directly. As an example, imagine I wanted to create a simple UILabel and pass in the content as a String and the style as UIFont. I could do this directly like this:

GroupItem<UILabel>(
  dataID: DataID.title,
  params: UIFont.preferredFont(forTextStyle: .body),
  content: "This is some body copy",
  make: { params in 
    let label = UILabel(frame: .zero)
    // this is required by LayoutGroups to ensure AutoLayout works as expected
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = params
    return label
  },
  setContent: { context, content in
    context.constrainable.text = content
  })

Creating components inline in EpoxyCollectionView

HGroupView and VGroupView are UIView subclasses that wrap an HGroup and VGroup respectively. You can use these if you want to create a view instance that contains a group, but you can also use these directly in EpoxyCollectionView as they both conform to EpoxyableView:

var items: [ItemModeling] {
  [
    VGroupView.itemModel(
      dataID: RowDataID.textRow,
      content: .init {
        Label.groupItem(
          dataID: GroupDataID.title,
          content: "Title text",
          style: .title)
        Label.groupItem(
          dataID: GroupDataID.subtitle,
          content: "Subtitle text",
          style: .subtitle)
      },
      style: .init(
        vGroupStyle: .init(spacing: 8),
        layoutMargins: .init(top: 16, left: 24, bottom: 16, right: 24))),
    HGroupView.itemModel(
      dataID: RowDataID.imageRow,
      content: .init {
        ImageView.groupItem(
          dataID: GroupDataID.image,
          content: UIImage(systemName: "folder"),
          style: .init(size: .init(width: 32, height: 32), tintColor: .systemGreen))
          .verticalAlignment(.top)
        VGroupItem(
          dataID: GroupDataID.verticalGroup,
          style: .init(spacing: 8))
        {
          Label.groupItem(
            dataID: GroupDataID.title,
            content: "Title text",
            style: .title)
          Label.groupItem(
            dataID: GroupDataID.subtitle,
            content: "Subtitle text",
            style: .subtitle)
          }
      },
      style: .init(
        hGroupStyle: .init(spacing: 16),
        layoutMargins: .init(top: 16, left: 24, bottom: 16, right: 24)))
  ]
}

Alignment

Each element in a group supports a set of alignments depending on which group they are in. For elements in an HGroup, you can set their Vertical alignment. This table shows all of the alignments supported by HGroup:

HGroup.ItemAlignment value Description
.fill Align top and bottom edges of the item tightly to the leading and trailing edges of the group. Components shorter than the group's height will be stretched to the height of the group
.top Align the top edge of an item tightly to the top edge of the group. Components shorter than the group's height will not be stretched.
.bottom Align the bottom edge of an item tightly to the container's bottom edge. Components shorter than the group's height will not be stretched.
.center Align the center of the item to the center of the group vertically. Components shorter than the group's height will not be stretched.
.centered(to: Constrainable) Vertically center one item to another item. The other item does not need to be in the same group, but it must share a common ancestor with the item it is centered to. Components shorter than the group's height will not be stretched.
.custom((_ container: Constrainable, _ constrainable: Constrainable) -> [NSLayoutConstraint]) Provide a block that returns a set of custom constraints. Parameter container: the parent container that should be constrained to. Parameter constrainable: the constrainable that this alignment is affecting

This table shows all of the Horizontal alignments which are supported in VGroup:

VGroup.ItemAlignment value Description
.fill Align leading and trailing edges of the item tightly to the leading and trailing edges of the group. Components shorter than the group's width will be stretched to the width of the group
.leading Align the leading edge of an item to the leading edge of the group. Components shorter than the group's width will not be stretched
.trailing Align the trailing edge of an item to the trailing edge of the group. Components shorter than the group's width will not be stretched
.center Align the center of the item to the center of the group horizontally. Components shorter than the group's width will not be stretched
.centered(to: Constrainable) Horizontally center one item to another. The other item does not need to be in the same group, but it must share a common ancestor with the item it is centered to. Components shorter than the group's width will not be stretched
.custom((_ container: Constrainable, _ constrainable: Constrainable) -> [NSLayoutConstraint]) Provide a block that returns a set of custom constraints. Parameter container: the parent container that should be constrained to. Parameter constrainable: the constrainable that this alignment is affecting

As an example, here's a CheckboxRow with each of the various vertical alignments used to change how the Checkbox aligns with the text:

Source Result
checkbox.verticalAlignemnt(.top)
checkbox row with top aligned checkbox
checkbox.verticalAlignemnt(.center)
checkbox row with center aligned checkbox
checkbox.verticalAlignemnt(.bottom)
checkbox row with bottom aligned checkbox
checkbox.verticalAlignemnt(.centered(to: subtitleLabel))
checkbox row with checkbox aligned to the center of the subtitle

A custom alignment that aligns the first baseline of the subtitle with the first baseline of the checkbox

checkbox.verticalAlignment(
  .custom { [weak self] container, view in
    guard let self = self else { return [] }
    return [
      view.leadingAnchor.constraint(
        equalTo: container.leadingAnchor),
      view.firstBaselineAnchor.constraint(
        equalTo: self.subtitleLabel.firstBaselineAnchor)
    ]
})
checkbox row with checkbox aligned to the center of the subtitle

Applying an alignment is simple, you just need to call the .verticalAlignment or .horizontalAlignment method on the view you want to align when setting up your group:

// HGroup's verticalAlignment
let hGroup = HGroup {
  checkbox
    .verticalAlignment(.center)
  titleLabel
}
hGroup.install(in: view)
hGroup.constrainToMargins()

// VGroup's horizontalAlignment
let vGroup = VGroup {
  checkbox
    .horizontalAlignment(.leading)
  titleLabel
}
vGroup.install(in: view)
vGroup.constrainToMargins()

Group alignments

HGroup and VGroup also accept alignment in their initializers which applies that alignment to every element in the group. If an element has an alignment set on it, it will use that instead of the group's alignment property. The default for both groups is .fill.

HGroup(alignment: .center) {
  imageView
  VGroup {
    titleLabel
    subtitleLabel
    actionLabel
  }
}

Accessibility layouts

One technique for making rows more accessible is to change the axis of the elements from being horizontal to vertical when the type size is set to something very large. By using HGroup you can get this behavior for free. An example of this can be seen with this message row, on the left is the row using default type size settings, and on the right is the same row using an accessibility type size setting:

Default Type Size Accessibility Type Size
Default type size message row Accessibility type size message row

Of course, you may not actually want your component (or parts of your component) to do this, so you can disable this behavior by setting the reflowsForAccessibilityTypeSizes = false on the HGroup.

public final class CheckboxRow: UIView {

  public init() {
    super.init()
    hGroup.install(in: self)
    hGroup.constrainToMargins()
  }

  private lazy var hGroup = HGroup {
    checkbox
    VGroup {
      titleLabel
      subtitleLabel
    }
  }
  .reflowsForAccessibilityTypeSizes(false)

}

Constrainable protocol

For both HGroup and VGroup to be able to accept UIView and other HGroup and VGroup instances, we’ve created a Constrainable protocol which defines something that can be laid out using auto layout:

/// Defines something that can be constrainted with AutoLayout
public protocol Constrainable {
  var leadingAnchor: NSLayoutXAxisAnchor { get }
  var trailingAnchor: NSLayoutXAxisAnchor { get }
  var leftAnchor: NSLayoutXAxisAnchor { get }
  var rightAnchor: NSLayoutXAxisAnchor { get }
  var topAnchor: NSLayoutYAxisAnchor { get }
  var bottomAnchor: NSLayoutYAxisAnchor { get }
  var widthAnchor: NSLayoutDimension { get }
  var heightAnchor: NSLayoutDimension { get }
  var centerXAnchor: NSLayoutXAxisAnchor { get }
  var centerYAnchor: NSLayoutYAxisAnchor { get }
  var firstBaselineAnchor: NSLayoutYAxisAnchor { get }
  var lastBaselineAnchor: NSLayoutYAxisAnchor { get }
  /// unique identifier for this constrainable
  var dataID: AnyHashable { get }
  /// View that owns this constrainable
  var owningView: UIView? { get }

  /// install the Constrainable into the provided view
  func install(in view: UIView)
  /// uninstalls the Constrainable
  func uninstall()
  /// equality function
  func isEqual(to constrainable: Constrainable) -> Bool
}

In the future we could conceivably create a ZGroup or any number of other layout objects that conform to this protocol, and they will all work together.

ConstrainableContainer

Internally, each HGroup and VGroup wraps each element inside of a ConstrainableContainer. This type gives access to the verticalAlignment and horizontalAlignment properties and will allow us to add new features to stacked elements in the future. Each time you call one of the alignment methods on an element in a group, it will wrap that element in a ConstrainableContainer. This prevents us from having to use associated objects or some other way to associate the alignment value with the element.

Complex component

Here’s the same MessageRow from before as an example of a more complex component built with HGroup, VGroup, and Spacer:

MessageRow
MessageRow screenshot

The code to create this component looks like this:

// Perform this as part of initialization of the component
let group = HGroup(spacing: 8)
group.install(in: self)
group.constrainToMargins()

// The setContent method can be called anytime and the group will
// perform an intelligent diff to only create, delete, move, or update views as needed
func setContent(_ content: Content, animated: Bool) {
  group.setItems {
    avatar
    VGroupItem(
      dataID: DataID.contentGroup,
      style: .init(spacing: 8))
    {
      HGroupItem(
        dataID: DataID.topContainer,
        style: .init(alignment: .center, spacing: 8))
      {
        HGroupItem(
          dataID: DataID.nameGroup,
          style: .init(alignment: .center, spacing: 8))
        {
          name(content.name)
          unreadIndicator
        }
        .reflowsForAccessibilityTypeSizes(false)
      
        SpacerItem(dataID: DataID.topSpacer)

        HGroupItem(
          dataID: DataID.disclosureGroup,
          style: .init(alignment: .center, spacing: 8))
        {
          date(content.date)
          disclosureIndicator
        }
        .reflowsForAccessibilityTypeSizes(false)
      }
      
      messagePreview(content.messagePreview)
      seenText(content.seenText)
    }
  }
}

// Computed variables and functions to create the nested group items

private var avatar: GroupItemModeling {
  ImageView.groupItem(
    dataID: DataID.avatar,
    content: UIImage(systemName: "person.crop.circle"),
    style: .init(
      size: .init(width: 48, height: 48),
      tintColor: .black))
    .set(\ImageView.layer.cornerRadius, value: 24)
}

private func name(_ name: String) -> GroupItemModeling {
  Label.groupItem(
    dataID: DataID.name,
    content: name,
    style: .style(with: .title3))
    .numberOfLines(1)
}

private var unreadIndicator: GroupItemModeling {
  ColorView.groupItem(
    dataID: DataID.unread,
    style: .init(size: .init(width: 8, height: 8), color: .systemBlue))
    .set(\ColorView.layer.cornerRadius, value: 4)
}

private func date(_ date: String) -> GroupItemModeling {
  Label.groupItem(
    dataID: DataID.date,
    content: date,
    style: .style(with: .subheadline))
    .contentCompressionResistancePriority(.required, for: .horizontal)
}

private var disclosureIndicator: GroupItemModeling {
  ImageView.groupItem(
    dataID: DataID.disclosureArrow,
    content: UIImage(systemName: "chevron.right"),
    style: .init(
      size: .init(width: 12, height: 16),
      tintColor: .black))
    .contentMode(.center)
    .contentCompressionResistancePriority(.required, for: .horizontal)
}

private func messagePreview(_ messagePreview: String) -> GroupItemModeling {
  Label.groupItem(
    dataID: DataID.message,
    content: messagePreview,
    style: .style(with: .body))
    .numberOfLines(3)
}

private func seenText(_ seenText: String) -> GroupItemModeling {
  Label.groupItem(
    dataID: DataID.seen,
    content: seenText,
    style: .style(with: .footnote))
}

Breaking down a component into elements and how those elements are stacked together allows you to create very complex components with very little code, and without having to manually create any constraints.

Accessing properties of underlying Constrainables

You might have noticed a few surprising calls in the MessageRow example, specifically the ones dealing with numberOfLines, the ImageView's cornerRadius, and the contentCompressionResistancePriority. GroupItem allows you to set any ReferenceWritableKeyPath of the underlying type by using dynamic member lookup, or by explicitly calling set(_ keypath:value:) with a provided keypath.

For example, a UILabel or subclass can have its numberOfLines set using dynamic member lookup:

Label.groupItem(
  dataID: DataID.message,
  content: content.messagePreview,
  style: .style(with: .body))
  .numberOfLines(3)

Since layer.cornerRadius is a nested call, we have to use an explicit keypath like this:

ImageView.groupItem(
  dataID: DataID.avatar,
  content: UIImage(systemName: "person.crop.circle"),
  style: .init(
    size: .init(width: 48, height: 48),
    tintColor: .black))
  .set(\ImageView.layer.cornerRadius, value: 24)

GroupItem has a few unique methods specifically for contentCompressionResistancePriority and contentHuggingPriority that you can use as follows:

ImageView.groupItem(
  dataID: DataID.disclosureArrow,
  content: UIImage(systemName: "chevron.right"),
  style: .init(
    size: .init(width: 12, height: 16),
    tintColor: .black))
  .contentMode(.center)
  .contentCompressionResistancePriority(.required, for: .horizontal)
  .contentHuggingPriority(.required, for: .horizontal)

Performance and Testing

I did some simple performance tests by implementing the same complex component seen above using LayoutGroups and UIStackView. Instruments showed that each implementation was comparable to each other (though admittedly it was a bit difficult to get good data on UIStackView). At Airbnb we have found that nesting multiple UIStackViews in a component and using that component many times in a screen can hinder scroll performance, whereas LayoutGroups do not have the same issue. You can profile this for yourself by building the Example project and profiling using the "Message List (LayoutGroups)" and "Message List (UIStackView)" screens.

There are also some basic performance tests that verify groups are at least as performant as UIStackView with the same configuration.

We've utilized the wonderful Swift Snapshot Testing to create snapshot tests of our demo view controllers to ensure there are not regressions between versions. As much of this code is UI related, it is challenging to write unit tests.

FAQ

What would I use HGroup / VGroup instead of UIStackView?

We've done some performance testing on UIStackView and have found that it can quickly degrade scroll performance when you have a lot of nested stack views in a UIScrollView. Here's a Medium article with similar findings. LayoutGroups aims to provide a much more efficient way of laying out subviews by not having a nested view hierarchy, and flattening the layout by using UILayoutGuide. On top of that, LayoutGroups provides a consistent declarative API that allows for efficient updates and a more reactive approach to programming.

Clone this wiki locally