Skip to content

Tutorial: Product Detail Page

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

Tutorial: Product Detail Page

Technologies covered in this tutorial

  • EpoxyCollectionView
  • EpoxyBars

Expected time taken: about 30-45 minutes

Part 1: Setup

In this tutorial we will be building a simple page used to display a product. Here is a screenshot of what the final page will look like:

A product page with an image and some text about the product

You can start by creating a new Xcode project and including the Epoxy package, or you can build directly in the EpoxyExample app. This tutorial will not cover setting up a new app with Epoxy as that is covered in the README. There is already a completed version of this page in the EpoxyExample app as ProductViewController which you can use for reference.

Start by creating a new Swift file, I called mine ProductViewController, but you can call it whatever you'd like. If you are working in the EpoxyExample repo you'll need to name it something else.

Part 2: Initializing the CollectionView

In your newly created Swift file, you'll need to set up a new class that inherits from EpoxyCollectionViewController:

import Epoxy

final class ProductViewController: EpoxyCollectionViewController {

}

EpoxyCollectionViewController requires a UICollectionViewLayout as part of the initializer, so you'll need to provide one. In our example we want a list of items similar to a TableView without dividers, and we can do that easily with UICollectionViewCompositionalLayout. I've created a new file to house this extension, but you can put it inline or whatever is best for you:

// UICollectionViewCompositionalLayout+List.swift

import UIKit

extension UICollectionViewCompositionalLayout {
  static func listNoDividers() -> UICollectionViewCompositionalLayout {
    UICollectionViewCompositionalLayout { _, _ in
      let item = NSCollectionLayoutItem(
        layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)))

      let group = NSCollectionLayoutGroup.vertical(
        layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)),
        subitems: [item])

      return NSCollectionLayoutSection(group: group)
    }
  }
}

We can now use this layout to initialize our superclass:

final class ProductViewController: EpoxyCollectionViewController {
  init() {
    super.init(layout: UICollectionViewCompositionalLayout.listNoDividers())
  }
}

Now that our CollectionViewController is properly set up, we need to tell it about the items we want to render. To do this using EpoxyCollectionViewController we just need to set an array of SectionModels:

final class ProductViewController: EpoxyCollectionViewController {

  init() {
    super.init(layout: UICollectionViewCompositionalLayout.listNoDividers())
    setSections(sections, animated: false)
  }

  private var sections: [SectionModel] {
    [
    ]
  }

}

Part 3: Rendering items in the CollectionView

We can then start building out our page in this items array by creating an ItemModel for each view we want to render. In order to keep track of which model is which, we associate a dataID with each model. The easiest way to create unique IDs for your items is by creating an enum:

private enum DataIDs {
  case imageHeader
  case titleRow
  case imageRow
}

Now we can create our items. Let's start with the image at the very top. We could use a UIImageView directly and set it up in our ItemModel, however we believe it's a best practice to create a reusable component for each element on our screen. When we do that, we can conform our component to EpoxyableView and capitalize on the many benefits of doing so.

Benefits of using EpoxyableView

  • A consistent API for all of your components
  • Allows you to use convenience initializers for ItemModel to make the creation of ItemModels easier, consistent, and less error-prone.
  • Separating out your component configuration into a set of immutable styles and settable content values keeps your components performant and easier to reason about

So with all that being said, lets create a new component for our image at the top. In the Example app we called this ImageMarquee.

The only additions we need when conforming to EpoxyableView are Content and Style models. The Content model contains all of the data you set on your view each time the cell gets dequeued and set up. The Style model contains everything that doesn't change about your component between renders. If you are used to vanilla UIKit you can think of your component as a UICollectionViewCell subclass, and the Content model is everything you would reset in prepareForReuse and the Style is anything that doesn't need to be reset. You can find out more about EpoxyableView in the EpoxyCore documentation.

import Epoxy
import UIKit

final class ImageMarquee: UIView, EpoxyableView {

  // MARK: Lifecycle
  init(style: Style) {
    self.style = style
    super.init(frame: .zero)
    contentMode = style.contentMode
    clipsToBounds = true
    addSubviews()
    constrainSubviews()
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // MARK: Internal

  // The height of the ImageView only needs to be configured once, which is
  // why it's in our style. If we included it in our content we would have
  // to update the NSLayoutConstraint every time this view came onscreen 
  // which could negatively impact performance
  struct Style: Hashable {
    var height: CGFloat
    var contentMode: UIView.ContentMode
  }

  // The imageURL will need to be set every time to ensure we are rendering
  // the correct image
  struct Content: Equatable {
    var imageURL: URL?
  }

  func setContent(_ content: Content, animated: Bool) {
    imageView.setURL(content.imageURL)
  }

  // MARK: Private
  private let style: Style
  private let imageView = UIImageView()

  private func addSubviews() {
    addSubview(imageView)
  }

  private func constrainSubviews() {
    imageView.translatesAutoresizingMaskIntoConstraints = false
    let heightAnchor = imageView.heightAnchor.constraint(equalToConstant: style.height)
    heightAnchor.priority = .defaultHigh
    NSLayoutConstraint.activate([
      heightAnchor,
      imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
      imageView.topAnchor.constraint(equalTo: topAnchor),
      imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
      imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
    ])
  }

}

Now that we have a component for our image, we can build the ItemModel for it like this:

ImageMarquee.itemModel(
  dataID: DataID.headerImage,
  content: .init(imageURL: URL(string: "https://picsum.photos/id/350/500/500")!),
  style: .init(height: 250, contentMode: .scaleAspectFill))

If you run the app, you should see the image view show up at the top of the screen!

Try adding the title row and image row components yourself. You can see examples of implementations of these components in TextRow and ImageRow in the Example app. Verify your implementation against the ProductViewController example in the EpoxyExample app.

Part 4: Adding a fixed bottom bar

We now need to add the fixed "Buy Now!" button at the bottom of the screen. We can accomplish this using BottomBarInstaller from EpoxyBars. We'll start by setting up the BottomBarInstaller and installing it in our viewDidLoad method:

final class ProductViewController: EpoxyCollectionViewController {
  ...

  override viewDidLoad() {
    super.viewDidLoad()
    bottomBarInstaller.install()
  }

  // MARK: Private
  
  private lazy var bottomBarInstaller = BottomBarInstaller(viewController: self)
}

Once the installer is installed, we need to provide it a set of bars to render. These follow the same conventions as EpoxyCollectionView. In the example below we have a component called ButtonRow which conforms to EpoxyableView:

override viewDidLoad() {
  super.viewDidLoad()
  bottomBarInstaller.setBars(bars, animated: false)
  bottomBartInstaller.install()
}

// MARK: Private

private var bars: [BarModeling] {
  [
    ButtonRow.barModel(content: .init(text: "Buy now"))
  ]
}

The BottomBarInstaller will handle all of the content inset adjustments and respect the safeAreaInsets for you, and ensure that the bar stays constrained to the bottom of the screen.

Re-run the app and you should have our final result!