-
Notifications
You must be signed in to change notification settings - Fork 76
EpoxyCollectionView
Epoxy's CollectionView
is a way of semantically declaring the layout of a screen. Each item in a collection view is represented by a model that represents a view, keeps track of its ID, and configures the view with data. These models are added to the Epoxy view in the order you want them to be displayed, and the Epoxy view handles the complexity of displaying them for you.
Declare an array of each view class and a closure for configuring each view with data, and Epoxy will handle displaying those views.
It can be used for clean, simple static pages as well as for complex pages with many view types that need to animate changes in response to user interaction or network requests. The API is just as simple in both cases.
In the simple static case, with this much code:
collectionView.setSections([
SectionModel(items: [
Row.itemModel(
dataID: DataID.first,
content: "first",
style: .standard),
Row.itemModel(
dataID: DataID.second,
content: "second",
style: .standard),
Row.itemModel(
dataID: DataID.third,
content: "third",
style: .standard),
])
], animated: true)
you can have a CollectionView rendering 3 rows with the provided content.
In a complex view that needs to animate changes, simply setting a new array of sections results in automatic animations with no extra work. Epoxy diffs internally between the two states and handles any view updates automatically.
Using index paths to refer to views on the screen can be great in the simplest case, but quickly becomes hairy in cases where views may be in different experimental states, or when asynchronous input such as user interactions or network requests can cause a view to become out of sync with its data source. Before Epoxy, this was a common source of crashes.
Epoxy avoids fragile, difficult-to-review code that's full of complex booleans in functions like cellForRowAtIndexPath
, didSelectRowAtIndexPath
, and numberOfRowsInSection
for the state or experimental setup. It handles internally mapping from the data you set to the index paths of the views, and it never gets out of sync. If you redesign your view or add an experiment, you're not risking introducing a bug that will cause an out-of-bounds crash, since all of the index path-related code is contained within Epoxy and doesn't change as your feature changes.
Instead of index paths, Epoxy uses dataID
s to refer to views.
It is a requirement of Epoxy to always include a unique dataID
for every row. Epoxy does print out warnings if you have a duplicate dataID
.
When you update the data, and refresh the content (e.g. by calling CollectionViewController.updateData()
), Epoxy uses these dataID
s to know that a view in the old data set should be animated as the same view as the view in the new data set, even if its content has changed. For example, in a text cell that's been updated to show the current number of items in a cart, Epoxy knows to animate the update to the content from state A to state B, instead of animating deleting the cell and inserting a new cell, since both cells share the same dataID
.
CollectionViewController
can be used as-is by passing in a set of sections, or you can subclass it and set the sections yourself. Here's an example of a subclass:
final class FeatureViewController: CollectionViewController {
init() {
super.init(layout: UICollectionViewCompositionalLayout.list())
setSections(sections, animated: false)
}
var sections: [SectionModel] {
[
SectionModel(items: items)
]
}
private enum DataIDs {
case title
}
private var items: [ItemModeling] {
[
ItemModel<UILabel, String>(
dataID: DataIDs.title,
content: "This is my title",
configureView: { context in
// context contains data coming from Epoxy to populate the content of your view
context.view.text = context.content
})
]
}
}
I could have created the same ViewController
by initializing a CollectionViewController
with the sections I want to render:
let viewController = CollectionViewController(
layout: UICollectionViewCompositionalLayout.list(),
sections: sections)
You can also use CollectionView
by itself without using CollectionViewController
. The CollectionView
class is a subclass of UICollectionView
, but must be configured using SectionModels
instead of using a delegate and data source. All you need to do is set up the CollectionView
with a layout, add it to the view hierarchy, and call setSections(_ sections: [SectionModel], animated: Bool)
to have Epoxy render your content. Here's an example implementation:
final class CustomCollectionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updateSections(animated: false)
}
// MARK: Private
// set up whatever UICollectionViewLayout you want
private lazy var collectionView = CollectionView(layout: ...)
private func updateSections(animated: Bool) {
collectionView.setSections(sections, animated: animated)
}
private var sections: [SectionModel] {
[
// set up items just as before
SectionModel(items: ...)
]
}
}
ItemModels
are the view models of Epoxy - they contain all of the information Epoxy needs to render a view for a given cell in the CollectionView
. ItemModels
have a few core properties:
// A simplified version of ItemModel to help explain the important properties
struct ItemModel<View: UIView, Content: Equatable> {
// A unique and stable ID that identifies this model.
let dataID: AnyHashable
// The content used to populate the view backed by the model. Content must be equatable for proper cell reuse.
let content: Content
// A closure invoked with context from Epoxy to give you a chance to set your view's content
let setContent: (CallbackContext, Content) -> Void
// A closure that returns the view that Epoxy will render inside the CollectionView cell. This will only be called when
// needed as views are reused.
let makeView: () -> View
}
You could create an ItemModel
directly like this:
ItemModel<MyCustomView>(
dataID: DataID.title,
content: "Hello world",
setContent: { context, content in
context.view.titleText = content
})
.makeView { MyCustomView() } // this is also the default
But, since we generally will want to configure the same type of view in a similar way, we can simplify this by having MyCustomView
conform to EpoxyableView
.
As long as your UIView
subclass conforms to EpoxyableView
from EpoxyCore
you can generate ItemModels
with a much nicer syntax than initializing them manually. In this example I have an ImageRow
component that conforms to EpoxyableView
:
// MARK: ImageRow
public final class ImageRow: UIView, EpoxyableView {
public init(style: Style) {
super.init(frame: .zero)
titleLabel.font = style.titleFont
subtitleLabel.font = style.subtitleFont
imageView.contentMode = style.contentMode
}
struct Style {
public var titleFont = UIFont.preferredFont(forTextStyle: .title2)
public var subtitleFont = UIFont.preferredFont(forTextStyle: .body)
public var imageContentMode = UIView.ContentMode.scaleAspectFill
public static var standard: Style {
.init()
}
}
struct Content {
let title: String
let subtitle: String
let imageURL: URL
}
func setContent(_ content: Content, animated: Bool) {
titleLabel.text = content.title
subtitleLabel.text = content.subtitle
imageView.setURL(content.url, animated: animated)
}
// Setup code down here to create the subviews and add them to ImageRow
}
Now, with some fancy Swift generics code, we can create the same ItemModel
like this:
ImageRow.itemModel(
dataID: DataID.imageRow,
content: .init(
title: "Title text",
subtitle: "Subtitle text",
imageURL: URL(string: "...")!),
style: .standard)
This convenience method will generate the setContent
and makeView
closures for you.
To construct an ItemModel
without conforming to EpoxyableView
looks like this:
let model = ItemModel<ImageRow>(
dataID: DataID.imageRow,
params: ImageRow.Style.standard,
content: ImageRow.Content(
title: "Title text",
subtitle: "Subtitle text",
imageURL: URL(string: "...")!),
makeView: { params in
ImageRow(style: params)
},
setContent: { context, content in
context.view.setContent(content)
})
ItemModels
are immutable, but you can use a chaining syntax to create new ItemModels
with set properties just like you would with SwiftUI
Views
:
let item = ImageRow.itemModel(
dataID: DataID.imageRow,
content: .init(
title: "Title text",
subtitle: "Subtitle text",
imageURL: URL(string: "...")!),
style: .standard)
.didSelect { context in
// Handle selection of this cell
}
Here are a list of modifiers you can use and how they relate to UICollectionView
:
Modifier | Discussion |
---|---|
content |
Data that will be included in the context struct that is passed into most Epoxy callbacks. This is data you need to populate the content of your view. |
dataID |
A unique identifier for this model. This must be unique for each model in a given section. |
didChangeState |
A closure invoked when the cell's state changes. The closure is provided a context struct which contains EpoxyCellState which is one of .normal , .highlighted , or .selected
|
didEndDisplaying |
A closure invoked when the cell containing this model's view stop being displayed |
isMovable |
Whether or not this item can be moved in the CollectionView. This property is associated with the CollectionViewEpoxyReorderingDelegate
|
makeView |
A closure invoked to build a view for this model when needed. This view will be reused. |
selectionStyle |
The style of selection for cells in the UICollectionView . Options are .noBackground and .color(UIColor)
|
setBehaviors |
A closure invoked when the cell needs behaviors reset. This is discussed in further detail below. |
setContent |
Closure called to set the content on the view. This is called whenever the UICollectionView asks for a cell to render. |
styleID |
Used to prevent cell reuse bugs when using the same View type with different initialized styles. This is discussed in more detail below. |
willDisplay |
A closure invoked when the cell containing this model's view will be displayed. |
Epoxy creates a reuseIdentifier
for you based on the ItemModel
you pass. That identifier is a combination of type(of: View)
on the ItemModel
and a hash of the Style
instance (this is why Style
needs to be Hashable
). If you are creating ItemModels
manually, you will need to provde a styleID
for each unique style of view that you are rendering on screen, otherwise you will run into issues from reusing the wrong cell.
Selection is handled by a didSelect
closure. This is set directly using the ItemModel
allowing you to co-locate the logic for selection with the creation of the view.
let items = images.map { imageData in
ImageRow.itemModel(
dataID: imageData.id,
content: .init(...),
style: .standard)
.didSelect { [weak self] _ in
self?.didSelectImage(id: imageData.id)
}
}
collectionView.setSections([SectionModel(items: items)], animated: true)
Setting a view's delegate also happens lazily after cells are created or recycled. Because of this, ItemModel
also handles setting a view's delegate using a block. Note that this must happen in the setBehaviors
closure and you must also nil out any blocks that are only set occassionally.
ImageRow.itemModel(
dataID: imageData.id,
content: .init(...),
style: .standard)
.setBehaviors { [weak self] context in
context.view.delegate = self
}
It is highly recommended that you utilize the BehaviorsSettableView
protocol instead, which allows you to define a set of non-equatable "behaviors" all at once and Epoxy
will take care of setting them when needed.
let behaviors = ImageRow.Behaviors(
didTapThumbnailImage: { [weak self] _ in
self?.navigateToImageViewer(forImageID: imageData.id)
}
)
let model = ImageRow.itemModel(
dataID: imageData.id,
content: .init(...),
style: .standard)
.behaviors(behaviors)
Views can update their visual state to show an altered highlighted or selected look, such as a darker background color. ItemModel
has an optional didChangeState
block that can be used to update the view's look for a different state. The didChangeState
block has a ItemCellState
parameter that is .normal
, .highlighted
, or .selected
, allowing views to display a unique look for each state if desired.
ImageRow.itemModel(
dataID: imageData.id,
content: .init(...),
style: .standard)
.didChangeState { context in
switch state {
case .normal:
context.view.backgroundColor = .white
case .highlighted, .selected:
context.view.backgroundColor = .lightGray
}
}
In UICollectionView
there are delegate callbacks for when the cell will display and when it ends displaying. In Epoxy these have been mapped to blocks on ItemModel
to be in line with Epoxy's goal of being a fully declarative UI framework.
ImageRow.itemModel(
dataID: imageData.id,
content: .init(...),
style: .standard)
.willDisplay {
// do something when the view will display
}
.didEndDisplaying {
// do something when the view ends displaying
}
You can use CollectionView
and CollectionViewController
with a standard UICollectionViewFlowLayout
while leveraging Epoxy
's declarative API. There are extensions to ItemModeling
and SectionModel
that provide a chain-able syntax for all of the UICollectionViewDelegateFlowLayout
methods. You can find a working example of this in the Example app under "Flow Layout demo".
ItemModeling
supports setting an item size like this:
Row.itemModel(
dataID: DataIDs.row,
content: .init(title: "My Row"),
style: .small)
.flowLayoutItemSize(.init(width: 250, height: 120))
As long as you initialize the CollectionView
or CollectionViewController
with a UICollectionViewFlowLayout
these values will automatically be used for the size of the item.
SectionModel
also has support for item size, and it applies that item size to every item in that section. SectionModel
has support for the rest of the normal delegate callbacks as well:
SectionModel(items: [...])
.flowLayoutSectionInset(.init(top: 0, left: 24, bottom: 0, right: 24))
.flowLayoutMinimumLineSpacing(8)
.flowLayoutMinimumInteritemSpacing(8)
.flowLayoutHeaderReferenceSize(.init(width: 0, height: 50))
.flowLayoutFooterReferenceSize(.init(width: 0, height: 50))
- 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