Implementing UICollectionView with horizontal scrolling and dynamic cell sizes

3 lata temu

One of my magic hours app feature will be the ability to search for a location and save it for the future use. I need a control that will display my stored locations and I decided that I’ll place it at the bottom of the app’s main screen with the ability to scroll them horizontally. This can be achieved with UICollectionView control. There’s a great introduction on the topic by Matteo Manferdini: The correct way to display lists in iOS and what many developers do wrong. I tried to follow his advice and implement my solution the right way :)

So I started by drag and dropping CollectionView control to my view and placing it at the bottom of my layout.

There’s the UICollectionView control itself and a cell placed directly under it. A cell is part of user interface that displays information about a single item from a collection. My cell contains only label for a location name.

The next step is providing a custom class, I called it LocationCell, deriving from UICollectionViewCell that will be dealing with my cell representing a location. This is my final implementation:

class LocationCell : UICollectionViewCell
{
@IBOutlet weak var locationNameLabel: UILabel!;
static let locationNameFont:UIFont = UIFont.systemFont(ofSize: 17, weight: UIFontWeightThin);
var locationName: String
{
didSet
{
locationNameLabel.text = locationName;
}
}
override var isSelected: Bool
{
didSet
{
locationNameLabel.textColor = isSelected ? UIColor.yellow : UIColor.white;
}
}
required init?(coder aDecoder: NSCoder)
{
self.locationName = "";
super.init(coder: aDecoder);
}
}

view raw
LocationCell.swift
hosted with ❤ by GitHub

Custom class has to be linked to cell in identity tab:

I had to also provide a string identifier for the cell, this can be any string, I chose my class name.

The next step was ctrl dragging the label to my new custom class to create an outlet locationNameLabel. I use this outlet for setting label’s contents to a location name and to change text color to yellow after the cell becomes selected. Collection and table views in iOS are designed to work with data source and delegate objects. Data source is an object that provides collection for the view and delegate is for dealing with cell sizes, selection changed actions and so on. I made one class conform to both protocols: UICollectionViewDataSource and UICollectionViewDelegateFlowLayout. In more complex scenarios this should be separated into two classes.

Object dealing as data source has to implement these two methods:

//Asks your data source object for the number of items in the specified section.
//func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{
return locations.count;
}
//Asks your data source object for the cell that corresponds to the specified item in the collection view.
//func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LocationCell", for: indexPath) as! LocationCell;
let location = locations[indexPath.row];
cell.locationName = location.name;
return cell;
}

view raw
LocationCell.swift
hosted with ❤ by GitHub

I initailize my data source object with list of my locations. The first method returns the length of array holding locations. In the second I’m dealing with my custom cell class. Setting locationName on cell object sets proper text in cell’s label through the outlet I’ve created.

Linking collection view control with data source object is done in main view controller in viewDidLoad method:

locationsList.dataSource = mainModel.dataSource!;
locationsList.reloadData();

view raw
ViewController.swift
hosted with ❤ by GitHub

where locationsList is outlet to UICollectionView control placed in my view.

After launching the app I saw this:

Labels in my collection are of fixed size and don’t expand to its contents. This doesn’t look good. I started googling for a solution, I thought this would be like one switch somewhere. Nope. This is where the delegate object enters the scene.

With this method I can specify how long my cell should be. This also means that I have to measure the length of string with specified font..

//Asks the delegate for the size of the specified item’s cell.
//If you do not implement this method, the flow layout uses the values in its itemSize property to set the size of items instead. Your implementation of this method can return a fixed set of sizes or dynamically adjust the sizes based on the cell’s content.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let width = locations[indexPath.row].name.width(withConstrainedHeight: 50, font: LocationCell.locationNameFont);
return CGSize(width: width + 10, height: 50);
}

Measuring is done in extension method:

extension String
{
func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat
{
let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height);
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.width;
}
}

view raw
Extensions.swift
hosted with ❤ by GitHub

I don’t really like the idea of having to know in code the exact font that will be used for displaying the location name. Maybe there is an easier solution? It took me long time to come up with a working solution but wouldn’t mind to change it :)

The last thing to implement is changing the location after tapping (selecting) the item in the list. First of all, make sure you set allowsSelection property to true:

locationsList.allowsSelection = true;
locationsList.dataSource = mainModel.dataSource!;
locationsList.delegate = mainModel.dataSource!;
locationsList.reloadData();

view raw
ViewController.swift
hosted with ❤ by GitHub

The didselectItem tells the delegate that the item at the specified index path was selected. The collection view calls this method when the user successfully selects an item in the collection view. I initialize my data source object with a delegate function that sets the new location with view. I could have added a reference to my main view controller, but I wanted to keep these two separate.

This is the final implementation of LocationDataSource class:

class LocationDataSource: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout
{
let locations: [LocationModel];
let setSelectedLocation: SetLocationDelegate;
init(locations: [LocationModel], delegate: @escaping SetLocationDelegate)
{
self.locations = locations;
self.setSelectedLocation = delegate;
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{
return locations.count;
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LocationCell", for: indexPath) as! LocationCell;
let location = locations[indexPath.row];
cell.locationName = location.name;
return cell;
}
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize
{
let width = locations[indexPath.row].name.width(withConstrainedHeight: 50, font: LocationCell.locationNameFont);
return CGSize(width: width + 10, height: 50);
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let location = locations[indexPath.row];
setSelectedLocation(location);
}
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath)
{
}
}

For now, I’m playing around with some sample locations. Did you know that Qaanaaq is one of the northernmost towns in the world? :)