NEW: Subscribe to Hacking with Swift+ and accelerate your learning! >>

SOLVED: Expand UITableViewCell (custom) on tap - some issues

Forums > iOS

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:

  1. 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 substiture 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.

  2. 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).

  3. 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"
    }
}

Any help appreciated!

   

In my experience expandable cells are not worth the hassle. I have configured tens of different table views and still have issues with expandables. I would create custom cell for the expanded content and show it after tapping the "main" cell

   

Mja, that's not really an answer 😏

But how would that go? I have no issues creating a separte class, but I think I will run into the same issues?

   

Hacking with Swift is sponsored by NSSpain

SPONSORED Announcing NSSpain 2020: Remote Edition! An online, continuous conference for iOS developers. We’ll start on Thursday and finish on Friday, with talks, activities, and lots of fun for 36 hours, non-stop. Sound good? Join us!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

@Nemecek-Filip How would you replace the cell class in didSelectRowAt ?

   

Well, the "trick" is not to replace the cell but instead show another one just below it thus creating an illusion of expansion. You can utilize table view sections for this. So if you have 10 items in your data array.

Then you will have 10 sections and each will have saved whether it is expanded or not. If not expanded numberOfRowsInSection will return 1, if expanded this method will return 2 to accomodate the secondary cell with content. You can then observe taps in didSelectRowAt and toggle something like isExpanded bool on the data model. You can then call reloadSections for this section to get animated expansion or collapse.

I tried illustrating it, take a look:

Collapsible table cells illustration

1      

Thanks! 👍

I see, but my tableView already has sections as well, that I need to collapse and recollapse as well. So I guess this is what I need for my sections (my next objective) , but that means I can't use it for my cells (that would mean nested sections) ...

   

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"
    }
}

And thanks again @Nemecek-Filip for all input!

Now only the reuse issue remains ...

   

I'm trying to add same scripti my blog post, but I'm facing the same error again and again.

   

@shanslkds What error is that ?

   

@Gakkienl hey there, I am currently working on almost the same kind of thing and https://www.youtube.com/watch?v=ClrSpJ3txAs&t=700s helped me a little. Are you trying to achieve something like this? Or am I missing something? It might be a little late but maybe somebody would benefit from this :]

   

Thanks for the reply. My cells AND section are collapsable/expandable. The YT vid uses section to make the impresiion of collapsable cells. I needed both I that't working fine for me now ...

1      

Hacking with Swift is sponsored by NSSpain

SPONSORED Announcing NSSpain 2020: Remote Edition! An online, continuous conference for iOS developers. We’ll start on Thursday and finish on Friday, with talks, activities, and lots of fun for 36 hours, non-stop. Sound good? Join us!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

Not logged in

Log in
 

Link copied to your pasteboard.