-
Notifications
You must be signed in to change notification settings - Fork 76
Tutorial: Product Detail Page
Technologies covered in this tutorial
EpoxyCollectionView
EpoxyBars
Expected time taken: about 30-45 minutes
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:
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.
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] {
[
]
}
}
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.
- A consistent API for all of your components
- Allows you to use convenience initializers for
ItemModel
to make the creation ofItemModels
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.
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!
- 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