Reputation: 475
I have looked at several other Stack Overflow questions, but I have been unable to find one that answers the specific question I have. A lot of the answers say to make a table view section expandable, but I want the row to be expandable and to be able to add a collection view to the row on expansion. And also, it would be best if it could be done without any external libraries.
I have a UITableView section that has rows in it that can be tapped to expand or collapse. When collapsed, the cell shows two lines of text in a vertical UIStackView on the left and an image and a down arrow on the right. The following picture shows the collapsed view and I am able to get this to work using AutoLayout constraints.
When expanded, there should be a UICollectionView added inside the cell and the row should animate expanding to show the collection view. The following picture shows roughly what it should look like. The grey area at the bottom is the collection view, which is a horizontal collection view that has a dynamic height.
But I am not sure how to go about adding the collection view to the UITableViewCell and how to animate that in. I have thought about creating the collection view in code, adding constraints to the left and right that pin it to the edge of the row (so it will be full width). Then I was going to add a constraint to the top of the collection view to pin it to the bottom of the table view cell and then in an animation block, I was going to change the constraint so that the bottom of the collection view is at the bottom of the table view cell and the top of the collection view is constrained to the bottom of the image view and set the compression resistance priority to 1000 to make sure the collection view is not compressed, but I ended up with unsatisfiable constraints and my constraints ended up being removed. I ended up giving up on that idea because I feel that it is too complicated and that there should be a more straightforward way of doing this.
Another way I have thought about doing this is to have two separate views in the same XIB file - one for the normal state and one for the expanded state. But then I am not sure how to go about animating all the changes in the row between states.
Does anyone have any ideas for how to add a collection view (or any view, I guess) to the bottom of a table view cell when it is expanded and how to animate that in? What are the best practices for doing something like this? Thank you for your help.
Upvotes: 0
Views: 729
Reputation: 77647
I would suggest...
.clipsToBounds = true
.constant = 0
.constant = 0
.constant
so it will load the cellsStart with the "container" view by itself... once you get it expanding / collapsing properly, then add in the collection view.
Edit here's a very basic example of an animated expand/collapse table view cell.
data struct - key & value strings, a "height" property (which would, presumably, be set when you know the height of the collection view for the row), and a "expanded" flag.
struct DemoStruct {
var key: String = ""
var value: String = ""
var cvHeight: CGFloat = 0
var expanded: Bool = false
}
cell class - two labels, expand/collapse button, "container" view below the labels:
class SampleTVCell: UITableViewCell {
// always visible elements
let keyLabel = UILabel()
let valLabel = UILabel()
let btn = UIButton()
// this will either hold the collectionView
// or be replaced by the collectionView
let containerView = UIView()
// will be set by the data
var containerHeight: NSLayoutConstraint!
// constraints for expanded/collapsed states
var expandedConstraint: NSLayoutConstraint!
var collapsedConstraint: NSLayoutConstraint!
// down/up chevrons
var expandImg: UIImage!
var collapseImg: UIImage!
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// make sure we get down/up button images
guard let imgD = UIImage(systemName: "chevron.down"),
let imgU = UIImage(systemName: "chevron.up")
else {
fatalError("Bad image loading!")
}
expandImg = imgD
collapseImg = imgU
// covers the bottom of the container view when cell is "collapsed"
let coverView = UIView()
coverView.backgroundColor = .white
[keyLabel, valLabel, btn, containerView, coverView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(v)
}
// set as desired
keyLabel.font = .systemFont(ofSize: 20.0, weight: .bold)
valLabel.font = .italicSystemFont(ofSize: 18.0)
// use the built-in layout margins
let g = contentView.layoutMarginsGuide
// .constant will be set by data
containerHeight = containerView.heightAnchor.constraint(equalToConstant: 0.0)
// constrain bottom of valLabel to bottom of margins
// priority will be updated when expanded/collapsed
collapsedConstraint = valLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor)
collapsedConstraint.priority = .defaultHigh
// constrain bottom of containerView to bottom of margins
// priority will be updated when expanded/collapsed
expandedConstraint = containerView.bottomAnchor.constraint(equalTo: g.bottomAnchor)
expandedConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
// button at upper-right
btn.topAnchor.constraint(equalTo: g.topAnchor),
btn.trailingAnchor.constraint(equalTo: g.trailingAnchor),
btn.heightAnchor.constraint(equalToConstant: 32.0),
btn.widthAnchor.constraint(equalTo: btn.heightAnchor, multiplier: 1.5),
// keyLabel at upper-left
keyLabel.topAnchor.constraint(equalTo: g.topAnchor),
keyLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
keyLabel.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -8.0),
// valLabel aligned and below keyLabel
valLabel.topAnchor.constraint(equalTo: keyLabel.bottomAnchor, constant: 4.0),
valLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
valLabel.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -8.0),
// containerView below valLabel
containerView.topAnchor.constraint(equalTo: valLabel.bottomAnchor, constant: 8.0),
containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
// activate our "control" constraints
collapsedConstraint,
expandedConstraint,
containerHeight,
// cover view sits at the very bottom of the contentView
// to cover the top part of the containerView
// when cell is "collapsed"
coverView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
coverView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
coverView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
coverView.heightAnchor.constraint(equalToConstant: contentView.layoutMargins.bottom),
])
// we don't want the labels to stretch or compress vertically
keyLabel.setContentCompressionResistancePriority(.required, for: .vertical)
valLabel.setContentCompressionResistancePriority(.required, for: .vertical)
keyLabel.setContentHuggingPriority(.required, for: .vertical)
valLabel.setContentHuggingPriority(.required, for: .vertical)
// action for button
btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
// Important!
contentView.clipsToBounds = true
// so we can see the containerView
// until we replace it with or add a collectionView as a subview
containerView.backgroundColor = .systemYellow
}
// closure so we can tell the controller we want the cell
// to collapse or expand
var expand: ((Bool)->())?
private var expanded: Bool = false {
didSet {
// set collapsed/expanded constraint priorities
collapsedConstraint.priority = expanded ? .defaultLow : .defaultHigh
expandedConstraint.priority = expanded ? .defaultHigh : .defaultLow
// update button image
btn.setImage(expanded ? collapseImg : expandImg, for: [])
}
}
func fillData(_ d: DemoStruct) -> Void {
keyLabel.text = d.key
valLabel.text = d.value
containerHeight.constant = d.cvHeight
expanded = d.expanded
}
@objc func btnTap(_ sender: Any?) -> Void {
expanded.toggle()
// tell the controller our expanded state changed
expand?(expanded)
}
}
controller class - generates 20 rows of data, with varying "collection view" heights.
class DemoTableViewController: UITableViewController {
var theData: [DemoStruct] = []
override func viewDidLoad() {
super.viewDidLoad()
// create 20 rows of sample data
theData = (1...20).map { DemoStruct(key: "Key \($0)", value: "Value \($0)", cvHeight: 40.0, expanded: false) }
// vary the containerView heights
// this will end up being the collectionView heights
let demoHeights: [CGFloat] = [
40, 160, 80, 120,
]
for i in 0..<theData.count {
theData[i].cvHeight = demoHeights[i % demoHeights.count]
}
tableView.register(SampleTVCell.self, forCellReuseIdentifier: "stvc")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return theData.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "stvc", for: indexPath) as! SampleTVCell
let d = theData[indexPath.row]
c.fillData(d)
// set the closure
c.expand = { isExpanded in
// update data
self.theData[indexPath.row].expanded = isExpanded
// this tells the tableView to animate cell height changes
tableView.performBatchUpdates(nil, completion: nil)
}
return c
}
}
Upvotes: 1