Skip to content

EpoxyCollectionView

Tyler Hedrick edited this page Feb 1, 2021 · 8 revisions

Overview

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.

Declarative views

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.

Simple update animations

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.

Avoiding index math

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 dataIDs 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.

DataIDs and Diffing Animations

When you update the data, and refresh the content (e.g. by calling CollectionViewController.updateData()), Epoxy uses these dataIDs 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

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)

CollectionView

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: ...)
    ]
  }

}

ItemModel and ItemModeling

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.

Using 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.

A note about reuse and styles

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.

Handling selection

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

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)

Hightlight and selection states

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
    }
  }

Responding to view appear / disappear events

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
  }

UICollectionViewFlowLayout

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))
Clone this wiki locally