Categories
iOS Swift UIKit

Building a list view with collection view in UIKit

UICollectionViewCompositionalLayout was an important change to how we create collection view layouts. In iOS14 Apple added a new static function to this class which creates a layout object for list views. Meaning, it is very easy to create a list views which look like a table view we are familiar with. The static list() function takes a configuration object UICollectionLayoutListConfiguration which allows further to configure the appearance of the header view. For example, supplementary header views are enabled here. In this blog post, we’ll create a list view with a collection view and use cell registration and diffable data source APIs.

List view created with diffable data source and collection view.

Generating data for the list view

Firstly, we’ll generate some data types which we want to display in the list view. The aim is to represent each Palette type with one section, and each PaletteColor is a row in the section.

struct Palette: Hashable {
let name: String
let colors: [PaletteColor]
// other properties
static let fancy = Palette(name: "Fancy", colors: [
PaletteColor(name: "Red", color: .systemRed),
PaletteColor(name: "Blue", color: .systemBlue),
PaletteColor(name: "Cyan", color: .systemCyan),
PaletteColor(name: "Mint", color: .systemMint),
PaletteColor(name: "Pink", color: .systemPink),
PaletteColor(name: "Teal", color: .systemTeal),
PaletteColor(name: "Green", color: .systemGreen),
PaletteColor(name: "Brown", color: .systemBrown)
])
static let secondary = Palette(name: "Secondary", colors: [
PaletteColor(name: "Label", color: .secondaryLabel),
PaletteColor(name: "Fill", color: .secondarySystemFill)
])
}
struct PaletteColor: Hashable {
let name: String
let color: UIColor
// other properties
}
view raw ListView.swift hosted with ❤ by GitHub

Configuring a collection view instance

We’ll create the collection view instance with a layout object which is configured to display lists. We go for insetGrouped appearance and turn on header views.

final class ListViewController: UIViewController {
private func makeCollectionView() -> UICollectionView {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .systemBackground
view.translatesAutoresizingMaskIntoConstraints = false
return view
}
private lazy var collectionView = makeCollectionView()
override func loadView() {
title = "Palettes"
view = UIView(frame: .zero)
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)
])
viewModel.reloadContent(in: dataSource)
}
}
view raw ListView.swift hosted with ❤ by GitHub

Configuring collection view data source and cell registration

The UICollectionViewDiffableDataSource is used for managing the data and also provides cells on demand. We’ll need to define section and item types when creating the data source. With the data we want to display, we’ll use String as a section type and PaletteColor as the item type when the section is just the name of the Palette’s name. In addition, we’ll use the cell and supplementary view registration APIs which keep the logic of creating different cells with a model object in the same place, which I find it to be really nice. Cells and supplementary views we’ll configure using the content configuration APIs which describe the data the cell or supplementary view displays. For list views, there is a specialized UIListContentConfiguration type which supports a variety of appearances. In many cases we do not need any custom cell classes at all since UIListContentConfiguration and UICollectionViewListCell takes care of it for us.

final class ListViewController: UIViewController {
let viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func makeDataSource() -> UICollectionViewDiffableDataSource<String, PaletteColor> {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, PaletteColor> { [viewModel] cell, indexPath, paletteColor in
var configutation = UIListContentConfiguration.cell()
configutation.image = viewModel.cellImage(for: paletteColor)
configutation.text = viewModel.cellTitle(for: paletteColor)
cell.contentConfiguration = configutation
}
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [viewModel] supplementaryView, elementKind, indexPath in
var configutation = UIListContentConfiguration.groupedHeader()
configutation.text = viewModel.headerTitle(in: indexPath.section)
supplementaryView.contentConfiguration = configutation
}
let dataSource = UICollectionViewDiffableDataSource<String, PaletteColor>(collectionView: collectionView, cellProvider: { collectionView, indexPath, paletteColor in
collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: paletteColor)
})
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
return dataSource
}
private lazy var dataSource = makeDataSource()
}
view raw ListView.swift hosted with ❤ by GitHub

Applying a snapshot

The final missing piece is creating a snapshot and applying it to the collection view data source which then tells the collection view what to render. If we would generate a new snapshot with slightly different data then the collection view only renders the changes between snapshots. No need to do this manually on our own.

extension ListViewController {
@MainActor final class ViewModel {
let palettes: [Palette]
init(palettes: [Palette]) {
self.palettes = palettes
}
func reloadContent(in dataSource: UICollectionViewDiffableDataSource<String, PaletteColor>) {
var snapshot = NSDiffableDataSourceSnapshot<String, PaletteColor>()
snapshot.appendSections(palettes.map(\.name))
palettes.forEach({ palette in
snapshot.appendItems(palette.colors, toSection: palette.name)
})
dataSource.apply(snapshot)
}
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

Summary

Diffable data sources with new cell registration APIs make a huge difference in how we implement collection views. Although it might take a bit of time to see how all the new APIs work together, I do not want to go back. Please check the example project for full code.

Example project

UIKitExampleDiffableListView (Xcode 13.3.1)

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift UIKit

Flow layout with self-sizing items and fixed spacing in UIKit

One of the really common layouts I have needed to implement with collection view is a simple flow layout but with fixed spacings. Apple provides us UICollectionViewFlowLayout, but the sad part is that it has dynamic spacing between items. Everything is there but not quite. Before UICollectionViewCompositionalLayout, one needs to create a subclass of the flow layout and then fixing spacings manually, which is pretty cumbersome to do. Therefore, let’s instead see what it takes to implement a simple self-sizing flow layout with fixed spacings when using UICollectionViewCompositionalLayout. The end goal is visible below, where we have a single section with 7 items.

Flow layout with fixed spacings.

UICollectionViewCompositionalLayout was created to be a flexible layout which allows building all sorts of layouts quickly. Data in that layout is divided into sections, where each section can have one or more groups of items. Grouping allows creating more complex layouts, where each group describes how items in the group are laid out in relation to each other. But in our case we have something really simple in mind, which is having self-sizing items which we can configure with NSCollectionLayoutSize and passing estimated dimensions. Then the next step is creating NSCollectionLayoutItem with that layout size and with some space around the item. The edge spacing with fixed edges gives us the wanted fixed spacing between items. After that, weā€™ll create NSCollectionLayoutGroup with horizontal layout direction and with a layout size which takes max width, but height is fitted based on item sizes. Creating layouts like this is so much better compared to subclassing UICollectionViewLayout and then calculating frames one by one. Down below is the configured layout object, which has fixed spacing and items are self-sizing.

extension UICollectionViewLayout {
static func fixedSpacedFlowLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .estimated(50),
heightDimension: .estimated(50)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(
leading: .fixed(8),
top: .fixed(4),
trailing: .fixed(8),
bottom: .fixed(4)
)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(100)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return UICollectionViewCompositionalLayout(section: section)
}
}

Example project can be found here: GitHub

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift UIKit

Height fitting collection view

I have numerous times needed to show some sort of collection view which adjusts its height based on the content. Most of the time it has been a dynamic list within some more complex scrollable UI. Therefore, in this post, we’ll take a look at how to set up a collection view which has its height set to the content height. On the screenshot below, we have a collection view with light grey background and two sections.

Collection view with height fitting size.

The approach for making this working is pretty simple, which involves adding a height constraint with the constant value set to collection view content height. Content height can be retrieved from the layout object. The constraint’s constant value can be updated in viewWillLayoutSubviews.

private var collectionViewHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.register(TextCollectionViewCell.self, forCellWithReuseIdentifier: Self.reuseIdentifier)
collectionView.backgroundColor = UIColor(white: 0.9, alpha: 1)
collectionView.isScrollEnabled = false
collectionViewHeightConstraint = collectionView.heightAnchor.constraint(equalToConstant: 50)
collectionViewHeightConstraint.isActive = true
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
collectionViewHeightConstraint.constant = collectionViewLayout.collectionViewContentSize.height
}

The full example collection view implementation can be seen here: FittingHeightCollectionView.

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift UIKit

UICollectionViewFlowLayout and auto layout on iOS

UICollectionViewFlowLayout is layout object supplied by UIKit and enables showing items in grid. It allows customising spacings and supports fixed size cells and cells with different sizes. This time I am going to show how to build a collection view containing items with different sizes where sizes are defined by auto layout constraints.

Cell sizes in UICollectionViewFlowLayout

By default UICollectionViewFlowLayout uses itemSize property for defining the sizes of the cells. Another way is to use UICollectionViewDelegateFlowLayout and supplying item sizes by implementing collectionView(_:layout:sizeForItemAt:). Third way is to let auto layout for defining the size of the cell. This is what I am going to build this time.

Enabling auto layout based cell sizes

When setting UICollectionViewFlowLayout’s estimatedItemSize to UICollectionViewFlowLayout.automaticSize enables layout object to use auto layout.

final class CollectionViewController: UICollectionViewController {
let content: [String]
init(content: [String]) {
self.content = content
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
super.init(collectionViewLayout: layout)
}
}

Setting up collection view cell with auto-layout constraints

When layout object is configured, second step is to create a cell. In the current prototype it is going to be a simple cell with a label and border around the label. Constraints are set up to have a 8 points space around the label. Constraints together with label’s intrinsicContentSize define the minimum size for the cell. If text is longer, intrinsicContentSize is wider.

final class TextCollectionViewCell: UICollectionViewCell {
let textLabel: UILabel
override init(frame: CGRect) {
textLabel = {
let label = UILabel(frame: .zero)
label.adjustsFontForContentSizeCategory = true
label.font = UIFont.preferredFont(forTextStyle: .body)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
super.init(frame: frame)
contentView.addSubview(textLabel)
NSLayoutConstraint.activate([
textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
textLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8),
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
])
contentView.layer.borderColor = UIColor.darkGray.cgColor
contentView.layer.borderWidth = 1
contentView.layer.cornerRadius = 4
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

When putting all the things together, the end result is a collection view where every cell has its own size. Moreover, it supports dynamic type and cells will grow if user changes default text sizes.

Summary

UICollectionViewFlowLayout’s estimatedItemSize enables using auto-layout for defining cell sizes. Therefore creating cells where text defines the size of the cell is simple to do on iOS.

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

FittingCellsInCollectionViewFlowLayout Xcode 10.2, Swift 5.0

Resources

Categories
iOS Swift

Circle shaped collection view layout on iOS

UICollectionViewLayout’s responsibility is to define layout: all the cell locations and sizes. It acts like a data source object which provides layout related information to the collection view. Collection view then uses that information for creating cells and placing them on screen. This time we’ll take a look on how to create a custom circle shaped layout.

Creating UICollectionViewLayout subclass

First step for creating a custom layout is to create UICollectionViewLayout subclass. Then we need to define the size of the layout. In this simple case, all the cells will fit into the available area – therefore we can just return the collection view’s own size.

override var collectionViewContentSize: CGSize {
return collectionView?.bounds.size ?? .zero
}

When-ever user rotates the device, the size of the collection view changes, therefore we would like to reset the layout.

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}

Prepare is called whenever layout gets invalidated and it is a best place where to pre-calculate cell locations. Then it is possible to return attributes in all of the other methods querying for attributes later on. This gives us the best possible performance.

Items are placed along a circle which is centred in the collection view. As collection view can be under navigation bar, then the frame of the circle needs to take account contentOffset. Moreover, as centre points of items are on the circle, we also need to take account the item size. Calculating positions for items is just a matter of applying simple trigonometry with right-angled triangles.

private var layoutCircleFrame = CGRect.zero
private let layoutInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
private let itemSize = CGSize(width: 100, height: 100)
private var itemLayoutAttributes = [UICollectionViewLayoutAttributes]()
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
itemLayoutAttributes.removeAll()
layoutCircleFrame = CGRect(origin: .zero, size: collectionViewContentSize)
.inset(by: layoutInsets)
.insetBy(dx: itemSize.width / 2.0, dy: itemSize.height / 2.0)
.offsetBy(dx: collectionView.contentOffset.x, dy: collectionView.contentOffset.y)
.insetBy(dx: -collectionView.contentOffset.x, dy: -collectionView.contentOffset.y)
for section in 0..<collectionView.numberOfSections {
switch section {
case 0:
let itemCount = collectionView.numberOfItems(inSection: section)
itemLayoutAttributes = (0..<itemCount).map({ (index) -> UICollectionViewLayoutAttributes in
let angleStep: CGFloat = 2.0 * CGFloat.pi / CGFloat(itemCount)
// CGRect extension for center and innerRadius: https://gist.github.com/laevandus/07955ff394984bda6de4922734429c84
var position = layoutCircleFrame.center
position.x += layoutCircleFrame.size.innerRadius * cos(angleStep * CGFloat(index))
position.y += layoutCircleFrame.size.innerRadius * sin(angleStep * CGFloat(index))
let indexPath = IndexPath(item: index, section: section)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = CGRect(center: position, size: itemSize)
return attributes
})
default:
fatalError("Unhandled section \(section).")
}
}
}

If we have pre-calculated attributes, then it is straight-forward to return them in the layout attributes methods. When adding or removing items, cells will move to new location with a nice animation by default.

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return itemLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let numberOfItems = collectionView?.numberOfItems(inSection: indexPath.section) ?? 0
guard numberOfItems > 0 else { return nil }
switch indexPath.section {
case 0:
return itemLayoutAttributes[indexPath.item]
default:
fatalError("Unknown section \(indexPath.section).")
}
}

I would like to highlight the fact that content presented here is just a bare minimum for setting up custom collection view layout. UICollectionView is highly configurable.

Setting up collection view

When custom collection view layout is ready, it is time to hook it up to the collection view. Custom collection view layout can be set in storyboard or when initialising collection view or collection view controller.

Setting custom layout in UIStoryBoard

Implementing a basic collection view controller for just showing one type of cells requires only a little bit of code. Data source methods provide the count of the items and sections and return configured collection view cells.

final class CollectionViewController: UICollectionViewController {
// MARK: Managing the View
enum ReuseIdentifier: String {
case item = "ItemCell"
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView!.register(CircleCollectionViewCell.self, forCellWithReuseIdentifier: ReuseIdentifier.item.rawValue)
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addItem))
}
// MARK: Managing the Content
private var items: [UIColor] = [.random(), .random(), .random()]
@objc private func addItem() {
items.append(.random())
collectionView.insertItems(at: [IndexPath(item: items.count – 1, section: 0)])
}
// MARK: Collection View Data Source
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return items.count > 0 ? 1 : 0
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReuseIdentifier.item.rawValue, for: indexPath) as! CircleCollectionViewCell
cell.backgroundView?.backgroundColor = items[indexPath.item]
cell.titleLabel.text = "\(indexPath.item)"
return cell
}
}

Summary

Adding custom collection view layout requires subclassing UICollectionViewLayout and overriding several methods. The core idea is to provide UICollectionViewAttributes for every cell which UICollectionView will then use for creating and laying out cells.

If this was helpful, please let me know on Mastodon@toomasvahter orĀ Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

CustomCollectionViewLayout (GitHub), Xcode 10.1, Swift 4.2

Resources