Search code examples
swiftuitableviewuikitswift5expand

Expand UITableViewCell (custom) on tap - autosize cell - some issues


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 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?

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


Solution

  • 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!