In my project (UIKit, programmatic UI) I have a UITableView with sections. The cells use a custom class. On load all cells just show 3 lines of info (2 labels). On tap, all contents will be displayed. Therefor I've setup my custom cell class to have two containers, one for the 3 line preview and one for the full contents. These containers are added/removed from the cell's content view when needed when the user taps the cell by calling a method (toggleFullView) on the custom cell class. This method is called from the view controller in didSelectRowAt:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let annotation = annotationsController.getAnnotationFor(indexPath)
//Expandable cell
guard let cell = tableView.cellForRow(at: indexPath) as? AnnotationCell else { return }
cell.toggleFullView()
tableView.reloadRows(at: [indexPath], with: .none)
// tableView.reloadData()
}
Basically it works, but there are some issues:
I have to double tap the cell for it to expand and again to make it collapse again. The first tap will perform the row animation of tableView.reloadRows(at: [indexPath], with: .none) and the second tap will perform the expanding. If I substitute reloadRows with tableView.reloadData() the expanding and collapsing will happen after a single tap! But that is disabling any animations obviously, it just snaps into place. How Do I get it to work with one tap?
When the cell expands, some other random cells are also expanded. I guess this has something to do with reusable cells, but I have not been able to remedy this. See the attached Video (https://www.youtube.com/watch?v=rOkuqMnArEU).
I want to be the expanded cell to collapse once I tap another cell to expand, how do I perceive that?
My custom cell class:
import UIKit
class AnnotationCell: UITableViewCell, SelfConfiguringAnnotationCell {
//MARK: - Properties
private let titleLabelPreview = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .headline))
private let titleLabelDetails = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .headline))
private let detailsLabelShort = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .subheadline), numberOfLines: 2)
private let detailsLabelLong = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .subheadline), numberOfLines: 0)
private let mapImageLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .footnote), andColor: .tertiarySystemGroupedBackground)
private let lastEditedLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .footnote), andColor: .tertiarySystemGroupedBackground)
private let checkmarkImageView = UIImageView()
private var checkmarkView = UIView()
private var previewDetailsView = UIStackView()
private var fullDetailsView = UIStackView()
private var showFullDetails = false
//MARK: - Init
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configureContents()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutIfNeeded() {
super.layoutIfNeeded()
let padding: CGFloat = 5
if contentView.subviews.contains(previewDetailsView) {
//Constrain the preview view
NSLayoutConstraint.activate([
previewDetailsView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
previewDetailsView.leadingAnchor.constraint(equalTo: checkmarkView.trailingAnchor, constant: padding),
previewDetailsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2 * padding),
previewDetailsView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding)
])
} else {
//Constrain the full view
NSLayoutConstraint.activate([
fullDetailsView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
fullDetailsView.leadingAnchor.constraint(equalTo: checkmarkView.trailingAnchor, constant: padding),
fullDetailsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2 * padding),
fullDetailsView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding)
])
}
}
//MARK: - Actions
///Expand and collapse the cell
func toggleFullView() {
showFullDetails.toggle()
if showFullDetails {
//show the full version
if contentView.subviews.contains(previewDetailsView) {
previewDetailsView.removeFromSuperview()
}
if !contentView.subviews.contains(fullDetailsView) {
contentView.addSubview(fullDetailsView)
}
} else {
//show the preview version
if contentView.subviews.contains(fullDetailsView) {
fullDetailsView.removeFromSuperview()
}
if !contentView.subviews.contains(previewDetailsView) {
contentView.addSubview(previewDetailsView)
}
}
UIView.animate(withDuration: 1.2) {
self.layoutIfNeeded()
}
}
//MARK: - Layout
private func configureContents() {
backgroundColor = .clear
separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
selectionStyle = .none
detailsLabelShort.adjustsFontSizeToFitWidth = false
detailsLabelLong.adjustsFontSizeToFitWidth = false
checkmarkView.translatesAutoresizingMaskIntoConstraints = false
checkmarkView.addSubview(checkmarkImageView)
checkmarkImageView.tintColor = .systemOrange
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
previewDetailsView = UIStackView(arrangedSubviews: [titleLabelPreview, detailsLabelShort])
previewDetailsView.axis = .vertical
previewDetailsView.translatesAutoresizingMaskIntoConstraints = false
previewDetailsView.addBackground(.blue)
fullDetailsView = UIStackView(arrangedSubviews: [titleLabelDetails, detailsLabelLong, mapImageLabel, lastEditedLabel])
fullDetailsView.axis = .vertical
fullDetailsView.translatesAutoresizingMaskIntoConstraints = false
fullDetailsView.addBackground(.green)
//By default only add the preview View
contentView.addSubviews(checkmarkView, previewDetailsView)
let padding: CGFloat = 5
NSLayoutConstraint.activate([
//Constrain the checkmark image view to the top left with a fixed height and width
checkmarkImageView.widthAnchor.constraint(equalToConstant: 24),
checkmarkImageView.heightAnchor.constraint(equalTo: checkmarkImageView.widthAnchor),
checkmarkImageView.centerYAnchor.constraint(equalTo: checkmarkView.centerYAnchor),
checkmarkImageView.centerXAnchor.constraint(equalTo: checkmarkView.centerXAnchor),
checkmarkView.widthAnchor.constraint(equalToConstant: 30),
checkmarkView.heightAnchor.constraint(equalTo: checkmarkView.widthAnchor),
checkmarkView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
checkmarkView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding)
])
self.layoutIfNeeded()
}
//MARK: - Configure cell with data
func configure(with annotation: AnnotationsController.Annotation) {
titleLabelPreview.text = annotation.title
titleLabelDetails.text = annotation.title
detailsLabelShort.text = annotation.details
detailsLabelLong.text = annotation.details
checkmarkImageView.image = annotation.complete ? ProjectImages.Annotation.checkmark : nil
lastEditedLabel.text = annotation.lastEdited.customMediumToString
mapImageLabel.text = annotation.mapImage?.title ?? "No map image attached"
}
}
Ok, got it fixed, a fully expanding tableview cell. Key things are invalidating the layout in the custom cell class and calling beginUpdates() and endUpdates() on the tableView!
In my viewController:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
//Expandable cell
guard let cell = tableView.cellForRow(at: indexPath) as? AnnotationCell else { return }
cell.toggleFullView()
tableView.beginUpdates()
tableView.endUpdates()
}
and my custom cell class with the toggleFullView() method:
class AnnotationCell: UITableViewCell, SelfConfiguringAnnotationCell {
//MARK: - Properties
private let titleLabelPreview = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .headline))
private let titleLabelDetails = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .headline))
private let detailsLabelShort = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .subheadline), numberOfLines: 2)
private let detailsLabelLong = ProjectTitleLabel(withTextAlignment: .left, andFont: UIFont.preferredFont(forTextStyle: .subheadline), numberOfLines: 0)
private let mapImageLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .footnote), andColor: .tertiarySystemGroupedBackground)
private let lastEditedLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .footnote), andColor: .tertiarySystemGroupedBackground)
private let checkmarkImageView = UIImageView()
private var checkmarkView = UIView()
private var previewDetailsView = UIStackView()
private var fullDetailsView = UIStackView()
let padding: CGFloat = 5
var showFullDetails = false
//MARK: - Init
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configureContents()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: - Actions
///Expand and collapse the cell
func toggleFullView() {
//Show the full contents
print("ShowFullDetails = \(showFullDetails.description.uppercased())")
if showFullDetails {
print("Show full contents")
if contentView.subviews.contains(previewDetailsView) {
previewDetailsView.removeFromSuperview()
}
if !contentView.subviews.contains(fullDetailsView) {
contentView.addSubview(fullDetailsView)
}
NSLayoutConstraint.activate([
fullDetailsView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
fullDetailsView.leadingAnchor.constraint(equalTo: checkmarkView.trailingAnchor, constant: padding),
fullDetailsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2 * padding),
fullDetailsView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding)
])
//Show preview contents
} else {
print("Show preview contents")
if contentView.subviews.contains(fullDetailsView) {
fullDetailsView.removeFromSuperview()
}
if !contentView.subviews.contains(previewDetailsView) {
contentView.addSubview(previewDetailsView)
}
NSLayoutConstraint.activate([
previewDetailsView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
previewDetailsView.leadingAnchor.constraint(equalTo: checkmarkView.trailingAnchor, constant: padding),
previewDetailsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2 * padding),
previewDetailsView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
showFullDetails.toggle()
//Invalidate current layout &
self.setNeedsLayout()
}
override func prepareForReuse() {
//Make sure reused cells start in the preview mode!
// showFullDetails = false
}
override func layoutIfNeeded() {
super.layoutIfNeeded()
NSLayoutConstraint.activate([
//Constrain the checkmark image view to the top left with a fixed height and width
checkmarkImageView.widthAnchor.constraint(equalToConstant: 24),
checkmarkImageView.heightAnchor.constraint(equalTo: checkmarkImageView.widthAnchor),
checkmarkImageView.centerYAnchor.constraint(equalTo: checkmarkView.centerYAnchor),
checkmarkImageView.centerXAnchor.constraint(equalTo: checkmarkView.centerXAnchor),
checkmarkView.widthAnchor.constraint(equalToConstant: 30),
checkmarkView.heightAnchor.constraint(equalTo: checkmarkView.widthAnchor),
checkmarkView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
checkmarkView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding)
])
}
//MARK: - Layout
private func configureContents() {
//Setup Views
backgroundColor = .clear
separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
selectionStyle = .none
detailsLabelShort.adjustsFontSizeToFitWidth = false
detailsLabelLong.adjustsFontSizeToFitWidth = false
checkmarkView.translatesAutoresizingMaskIntoConstraints = false
checkmarkView.addSubview(checkmarkImageView)
checkmarkImageView.tintColor = .systemOrange
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
previewDetailsView = UIStackView(arrangedSubviews: [titleLabelPreview, detailsLabelShort])
previewDetailsView.axis = .vertical
previewDetailsView.translatesAutoresizingMaskIntoConstraints = false
previewDetailsView.addBackground(.blue)
fullDetailsView = UIStackView(arrangedSubviews: [titleLabelDetails, detailsLabelLong, mapImageLabel, lastEditedLabel])
fullDetailsView.axis = .vertical
fullDetailsView.translatesAutoresizingMaskIntoConstraints = false
fullDetailsView.addBackground(.green)
//By default only show the preview View
contentView.addSubviews(checkmarkView)
//Setup preview/DetailView
toggleFullView()
}
//MARK: - Configure cell with data
func configure(with annotation: AnnotationsController.Annotation) {
titleLabelPreview.text = annotation.title
titleLabelDetails.text = annotation.title
detailsLabelShort.text = annotation.details
detailsLabelLong.text = annotation.details
checkmarkImageView.image = annotation.complete ? ProjectImages.Annotation.checkmark : nil
lastEditedLabel.text = annotation.lastEdited.customMediumToString
mapImageLabel.text = annotation.mapImage?.title ?? "No map image attached"
}
}
HTH!