Changing UITableViewCell height with ease

Uladzislau Volchyk
4 min readJan 30, 2021

Introduction

Implementing an expandable table cell may cause a headache (as a programming at all) In this article we are going to make such cell in a pretty simple way.

Domain

First, we need to define a data to work with. They will be represented by News struct. ViewData class serves a decorator.

struct News {
let title: String
let summary: String
let date: String
}

class ViewData {
private let raw: News
var expanded: Bool

var title: String { raw.title }
var summary: String { raw.summary }
var date: String { raw.date }

init(_ data: News) {
raw = data
expanded = false
}
}

View controller is pretty simple. Acts as a dataSource for tableView, provides data and methods implementations.

Method tableView(_:cellForRowAt:) is the most interesting here. it’s arguments are ViewData object and closure. The latter accepts in parameters another closure, called callback. This closure is configured within cell, what we will see later. And this closure is invoked within performBatchUpdates(_:completion:)method of tableView, what makes the significant part of all the magic with expansion.

final class MagicController: UIViewController {

let data: [ViewData] = [.init(.init(title: "Article", summary: "Summary", date: "18.05.2024"))]

private lazy var tableView: UITableView = {
let view = UITableView()
view.translatesAutoresizingMaskIntoConstraints = false
view.dataSource = self
view.register(MagicCell.self, forCellReuseIdentifier: MagicCell.reuseIdentifier)
view.tableFooterView = .init()
return view
}()

override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)

NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}

extension MagicController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
data.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: MagicCell.reuseIdentifier, for: indexPath) as? MagicCell
else { return UITableViewCell() }

cell.configureWithData(data[indexPath.row]) { [weak tableView] (callback) in
tableView?.performBatchUpdates {
callback()
}
}

return cell
}
}

And now the most important part… cell.

final class MagicCell: UITableViewCell {
private enum Constants {
static let kPadding: CGFloat = 20
static let kAnimationDuration: TimeInterval = 0.3
static let kAnimationDelay: TimeInterval = 0

static let kTextSpacing: CGFloat = 10
static let kFondSize: CGFloat = 20
}

static var reuseIdentifier: String { String(describing: Self.self) }

var model: ViewData!
var expandCallback: ((() -> ()) -> ())!

private lazy var mainStack: UIStackView = {
let view = UIStackView()
view.translatesAutoresizingMaskIntoConstraints = false
view.axis = .vertical
view.spacing = Constants.kTextSpacing

view.addArrangedSubview(self.summaryLabel)
view.addArrangedSubview(self.additionalStack)
return view
}()

private lazy var additionalStack: UIStackView = {
let view = UIStackView()
view.translatesAutoresizingMaskIntoConstraints = false
view.axis = .horizontal
view.distribution = .equalCentering

view.addArrangedSubview(self.dateLabel)
view.addArrangedSubview(self.expandButton)
return view
}()

private lazy var titleLabel: UILabel = {
let view = UILabel()
view.translatesAutoresizingMaskIntoConstraints = false
view.font = .systemFont(ofSize: Constants.kFondSize, weight: .bold)
view.numberOfLines = .zero
view.lineBreakMode = .byWordWrapping
return view
}()

private lazy var summaryLabel: UILabel = {
let view = UILabel()
view.numberOfLines = .zero
view.lineBreakMode = .byWordWrapping
return view
}()

private lazy var dateLabel: UILabel = {
let view = UILabel()
view.textColor = .darkGray
return view
}()

private lazy var expandButton: UIButton = {
let view = UIButton()
view.setImage(UIImage(systemName: "ellipsis"), for: .normal)
view.addTarget(self, action: #selector(expandButtonTap), for: .touchUpInside)
return view
}()

// MARK: - Init

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("Not implemented 🙃")
}

// MARK: - Layout

private func setupViews() {
contentView.addSubview(mainStack)
contentView.addSubview(titleLabel)

NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.kPadding),
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Constants.kPadding),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.kPadding),
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: mainStack.topAnchor, constant: -Constants.kPadding),

mainStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.kPadding),
mainStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Constants.kPadding),
mainStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.kPadding),
mainStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Constants.kPadding)
])
}

// MARK: -

func configureWithData(_ data: ViewData, completion: @escaping (() -> ()) -> ()) {
model = data
expandCallback = completion
titleLabel.text = data.title
summaryLabel.text = data.summary
summaryLabel.isHidden = !data.expanded
dateLabel.text = data.date
}

@objc func expandButtonTap() {
expandCallback { [unowned self] in
self.model.expanded.toggle()

let titleHeight = self.titleLabel.frame.height

UIView.animateKeyframes(
withDuration: Constants.kAnimationDuration,
delay: Constants.kAnimationDelay,
options: .calculationModeLinear
) {

self.titleLabel.heightAnchor.constraint(equalToConstant: titleHeight).isActive = true
self.summaryLabel.isHidden = !self.model.expanded
} completion: { (finished) in
self.titleLabel.heightAnchor.constraint(equalToConstant: titleHeight).isActive = false
}
}
}
}

In the configuration method we save completion, provided by the controller, and set isHidden property of summaryLabel to an appropriate relatively to the current item state.

The stored callback closure is invoked when the expandButtonis clicked. And as a parameter is passed another closure, where the rest part of the whole magic is happening.

First of we toggle the model expansion state. Then within animation method we set within isHidden property of summaryLabel appropriate inverted value of the current model. Inverted because the expanded state means no hidden labels.

Additionally we freeze titleLabel height using constraint for a period of animation. It’s important because titleLabel’s bottom anchor is linked to mainStack’s top and when mainStack recalculates it’s height, this action affects on the titleLabel and makes it to recalculate it’s height too. And to prevent this we freeze height.

And result:

Outro

Hope this article was helpful and you found out to deal your problem. 😃

--

--