-
Notifications
You must be signed in to change notification settings - Fork 76
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.
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 |
---|
// 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 |
---|
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()
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
}
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 HGroup s in a parent group |
VGroupItem |
An item that represents a VGroup that should be used when nesting VGroup s 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 |
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 |
---|
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 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 |
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()
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
})
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)))
]
}
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.verticalAlignemnt(.center) |
|
checkbox.verticalAlignemnt(.bottom) |
|
checkbox.verticalAlignemnt(.centered(to: subtitleLabel)) |
|
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)
]
}) |
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()
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
}
}
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 |
---|---|
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)
}
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.
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.
Here’s the same MessageRow
from before as an example of a more complex component built with HGroup
, VGroup
, and Spacer
:
MessageRow |
---|
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.
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)
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.
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.
- Overview
ItemModel
andItemModeling
- Using
EpoxyableView
CollectionViewController
CollectionView
- Handling selection
- Setting view delegates and closures
- Highlight and selection states
- Responding to view appear / disappear events
- Using
UICollectionViewFlowLayout
- Overview
GroupItem
andGroupItemModeling
- Composing groups
- Spacing
StaticGroupItem
GroupItem
withoutEpoxyableView
- Creating components inline
- Alignment
- Accessibility layouts
- Constrainable and ConstrainableContainer
- Accessing properties of underlying Constrainables