Implementing Sticky Cell in UICollectionView or UITableView - ios

I am to implement a table with a list of items which includes one item that should always be onscreen. So, for example:
you have 50 items in the list
your "sticky" list item is 25th
you have 10 items that may be displayed onscreen at a time
despite of you position in the list, "sticky" list should always remain visible
if your item is lower than your position in the list it is displayed on the bottom of the list
if your item is between previous items it should be displayed on the top of the list
as soon as you reach you item's real position in the list, it should move together with the scroll of the list
Here are the illustrations to better understand the implementation requirements:
Will be glad for any possible ideas, suggestions or recommendations on how can this possibly implemented. Unfortunately, I failed to find any useful libraries or solutions that solve this problem. UICollectionView and UITableView are both acceptable for this case.
Sticky header or footer, as per my understanding do not work in this case as they cover only half of the functionality that I need.
Thank you in advance for your comments and answers!!!

I'm pretty sure you can't actually have the same actual cell be sticky like that. You can create the illusion of stickiness through auto layout trickery though. Basically, my suggestion is that you can have views that are the same as your cells that you want to be "sticky" and constrain them on top of your sticky cells while your sticky cells are visible. The best I could pull off on this doesn't look quite perfect if you scroll slowly. (The sticky cell goes mostly off screen before snapping to the top or bottom position. It isn't noticeable in my opinion at fairly normal scrolling speeds. Your mileage may vary.)
The key is setting up a table view delegate so you can get notified about when the cell will or will not be on the screen.
I've included an example view controller. I'm sure there are areas where my example code won't work. (For example, I didn't handle stacking multiple "sticky" cells, or dynamic row heights. Also, I made my sticky cell blue so it would be easier to see the stickiness.)
In order to run the example code, you should just be able to paste it into a default project Xcode generates if you create a new UIKit app. Just replace the view controller they gave you with this one to see it in action.
import UIKit
struct StickyView {
let view: UIView
let constraint: NSLayoutConstraint
}
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var stickyViewConstraints = [Int: StickyView]()
lazy var tableView: UITableView = {
let table = UITableView()
table.translatesAutoresizingMaskIntoConstraints = false
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
table.rowHeight = 40
table.dataSource = self
table.delegate = self
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
addTable()
setupStickyViews()
}
private func addTable() {
view.addSubview(tableView)
tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
}
private func setupStickyViews() {
let cell25 = UITableViewCell()
cell25.translatesAutoresizingMaskIntoConstraints = false
cell25.backgroundColor = .blue
cell25.textLabel?.text = "25"
view.addSubview(cell25)
cell25.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
cell25.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
cell25.heightAnchor.constraint(equalToConstant: tableView.rowHeight).isActive = true
let bottom = cell25.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
bottom.isActive = true
stickyViewConstraints[25] = StickyView(view: cell25, constraint: bottom)
}
// MARK: - Data Source
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return section == 0 ? 50 : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "\(indexPath.row)"
return cell
}
// MARK: - Delegate
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let stickyView = stickyViewConstraints[indexPath.row] else { return }
stickyView.constraint.isActive = false
var verticalConstraint: NSLayoutConstraint
if shouldPlaceStickyViewAtTop(stickyRow: indexPath.row) {
verticalConstraint = stickyView.view.topAnchor.constraint(equalTo: view.topAnchor)
} else {
verticalConstraint = stickyView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
}
verticalConstraint.isActive = true
stickyViewConstraints[indexPath.row] = StickyView(view: stickyView.view, constraint: verticalConstraint)
}
private func shouldPlaceStickyViewAtTop(stickyRow: Int) -> Bool {
let visibleRows = tableView.indexPathsForVisibleRows?.map(\.row)
guard let min = visibleRows?.min() else { return false }
return min > stickyRow
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if let stickyView = stickyViewConstraints[indexPath.row] {
stickyView.constraint.isActive = false
let bottom = stickyView.view.bottomAnchor.constraint(equalTo: cell.bottomAnchor)
bottom.isActive = true
stickyViewConstraints[indexPath.row] = StickyView(view: stickyView.view, constraint: bottom)
}
}
}

Related

Add UITableView as viewForFooterInSection within a tableView

I'm trying to add a loop of custom UIViews at the end of a previous tableView so I have two consecutive lists inside the same VC.
As I wanted the scroll to be global and not into two separates tableViews I tried to add content with a loop inside viewForFooterInSection method.
As you can imagine it didn't go as planned. The debugger doesn't give me any errors but my UI does not show anything too. I'm wondering if someone would know how it could be achieved.
Here is a screen of what I want to do:
As you can see it should be two consecutive tableViews but I don't want them to be scrolling apart from each other. I want the flow of the page to be one big scroll.
Here is my code :
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let footerView = UIView()
self.getBestUsers( completion: { // API call
self.bestUsers.enumerated().forEach { (index, user) in // Loop
DispatchQueue.main.async {
let footer = self.debateList.dequeueReusableCell(withIdentifier: "bestUsersBox") as! UserBox
self.tableView.tableFooterView = footer
footer.setNeedsLayout()
footer.layoutIfNeeded()
footer.frame.size = footer.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
footer.userBoxView.user = user
footerView.addSubview(footer)
footer.topAnchor.constraint(equalTo: footerView.topAnchor, constant: 0).isActive = true
footer.bottomAnchor.constraint(equalTo: footerView.bottomAnchor, constant: 0).isActive = true
footer.leftAnchor.constraint(equalTo: footerView.leftAnchor, constant: 0).isActive = true
footer.rightAnchor.constraint(equalTo: footerView.rightAnchor, constant: 0).isActive = true
}
}
})
footerView.translatesAutoresizingMaskIntoConstraints = false
return footerView
}
[1]: https://i.stack.imgur.com/9pN9u.png
So, I figured out how to achieve what I wanted. Sorry if this wasn't clear.
I just used two different sections to add my content with different sources :
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let loadCell = debateList.dequeueReusableCell(withIdentifier: "loadMore", for: indexPath) as! LoadMore
if indexPath.section == 0 {
let debateCell = debateList.dequeueReusableCell(withIdentifier: "debateBox", for: indexPath) as! DebateBox
if trendingDebates.count > 0 {
if (indexPath.row == trendingDebates.count) {
loadCell.buttonTapCallback = {
self.loadMoreDebates()
}
if trendingDebates.count < totalItems {
return loadCell
} else {
return UITableViewCell()
}
} else {
let currentDebate = trendingDebates[indexPath.row]
debateCell.debateBoxView.debate = currentDebate
return debateCell
}
}
} else {
let userCell = debateList.dequeueReusableCell(withIdentifier: "bestUsersBox", for: indexPath) as! UserBox
userCell.userBoxView.user = bestUsers[indexPath.row]
return userCell
}
return UITableViewCell()
}

How to adjust left and right insets or margins for UITableView tableHeaderView?

I have a "plain" style UITableView. I am setting a view as the tableViewHeader for the table view. The table also shows the section index down the right side.
My issue is figuring out how to inset the left and right edge of the header view to take into account safe area insets if run on an iPhone X (in landscape) and the table view's section index (if there is one).
I created a simple test app that adds a few dummy rows, a section header, and the section index.
Here is my code for creating a simple header view using a UILabel. My real app won't be using a label but a custom view.
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 30)
label.backgroundColor = .green
label.text = "This is a really long Table Header label to make sure it is working properly."
label.sizeToFit()
tableView.tableHeaderView = label
Without any special attempts to fix the left and right edges, the result in the iPhone X simulator is as follows:
Portait:
Landscape:
Note that without any extra effort, the cells and section header get the desired insets but the header view does not.
I'd like the left edge of the header view to line up with the left edge of the section header and the cells.
I'd like the right edge of the header view to stop where it meets the left edge of the section index. Note that the portrait picture seems like it is already do that but if you look close you can tell the header view goes all the way to the right edge of the table view. You can't see the third . of the ellipses and you can barely see the green behind the section title view.
One attempt I've made was to add the following to my table view controller:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let header = tableView.tableHeaderView {
var insets = header.layoutMargins
insets.left = tableView.layoutMargins.left
insets.right = tableView.layoutMargins.right
header.layoutMargins = insets
}
}
That code has no effect.
What properties do I set to ensure the header view's left and right edges are indented as needed? Are there constraints that should be applied?
Please note that I'm doing everything in code. So please don't post any solutions that require storyboards or xib files. Answers in either Swift or Objective-C are welcome.
For anyone that wants to replicate this, create a new single view project. Adjust the main storyboard to use a UITableViewController instead of a plan UIViewController and use the following for ViewController:
import UIKit
class ViewController: UITableViewController {
// MARK: - UITableViewController methods
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 5
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "Row \(indexPath.row)"
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return "Section Header"
}
override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
let coll = UILocalizedIndexedCollation.current()
return coll.sectionIndexTitles
}
override func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int {
return index
}
// MARK: - UIViewController methods
override func viewDidLoad() {
super.viewDidLoad()
tableView.sectionIndexMinimumDisplayRowCount = 1
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 30)
label.backgroundColor = .green
label.text = "This is a really long Table Header label to make sure it is working properly."
label.sizeToFit()
tableView.tableHeaderView = label
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let header = tableView.tableHeaderView {
var insets = header.layoutMargins
insets.left = tableView.layoutMargins.left
insets.right = tableView.layoutMargins.right
header.layoutMargins = insets
}
}
}
For UITableViews without section index:
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])
For UITableViews with section index:
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: tableView.layoutMarginsGuide.trailingAnchor)
])
You need to add some Auto Layout constraints to the label after you add it to the tableview:
…
tableView.tableHeaderView = label
//Add this
label.translatesAutoresizingMaskIntoConstraints = false
label.leadingAnchor.constraint(equalTo: (label.superview?.safeAreaLayoutGuide.leadingAnchor)!).isActive = true
label.trailingAnchor.constraint(equalTo: (label.superview?.safeAreaLayoutGuide.trailingAnchor)!).isActive = true
Also, if you want to see all the text in the label use label.numberOfLines = 0.
You can get rid of the code you added to viewDidLayoutSubviews.
Update:
For fun I did some experimenting in a playground and found that using the layoutMarginsGuide didn't push the trailing edge of the header label far enough over (I'm thinking it comes out right on iPhone X but maybe not on all devices, or the playground behaves a bit differently). I did find though that for table views with at least one cell already in place I could use aCell.contentView.bounds.width, subtract the table view's width and divide the result by two and the header label would sit very nicely next to the section index. As a result I wrote a helper function for setting up constraints. The table view is optional so the function can be used with any view that has a superview and needs to keep inside the safe area. If a table view is passed in it can have a section index or not but it does need to have at least one cell at row 0 section 0:
func constrain(view: UIView, inTableView aTableView: UITableView?) {
guard let container = view.superview else {
print("Constrain error! View must have a superview to be constrained!!")
return
}
view.translatesAutoresizingMaskIntoConstraints = false
view.leadingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.leadingAnchor).isActive = true
if let table = aTableView, let aCell = table.cellForRow(at: IndexPath(row: 0, section: 0)) {
let tableWidth = table.bounds.width
let cellWidth = aCell.contentView.bounds.width
view.trailingAnchor.constraint(equalTo: table.safeAreaLayoutGuide.trailingAnchor, constant: cellWidth - tableWidth).isActive = true
} else {
view.trailingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.trailingAnchor).isActive = true
}
}
I did find one issue when using this. When using a label set to 0 lines with your text it covers the first section header and the first cell of that section. It takes a bit of scrolling to get them out from under the header too. Clipping the label to one line works out quite well though.

Animating NSLayoutConstraint in didSelectRowAtIndexPath doesn't work

I have a MainVC(UIViewController) with a MainTableVC(UITableView) and another nested SubTableVC(UITableView).
Both of them has the MainVC as its delegate and dataSource, also, each one has its own restorationIdentifier so I can distinguish between them when I call a delegate or dataSource function.
Here's an example:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch tableView.restorationIdentifier {
case "MainTableVCID":
return 10
case "SubTableVC":
return 5
default:
return 0
}
}
That's working perfectly great. But one issue I have here and I couldn't solve is:
I have a UIPickerView that's a subview of the MainVC's view, it's constrained just below the MainVC's view with constraints like this:
private let pickerView: UIPickerView = {
let picker = UIPickerView()
picker.backgroundColor = UIColor.lightGray
picker.translatesAutoresizingMaskIntoConstraints = false
return picker
}()
private var pickerViewBottomAnchor: NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
//other init code..
view.addSubview(pickerView)
pickerView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
pickerView.heightAnchor.constraint(equalToConstant: 180).isActive = true
pickerViewBottomAnchor = pickerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 180)
pickerViewBottomAnchor?.isActive = true
pickerView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
}
In the nested tableView (SubTableVC), in tableView:didSelectRowAtIndexPath: I want to animate the NSLayoutConstraint's constant to be equal to Zero.
However, at this time, I get pickerViewBottomAnchor = nil when I use console to print object (po self. pickerViewBottomAnchor?.constant) //prints nil.
Here's the code I use:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if tableView.restorationIdentifier == "SubTableVC" {
pickerViewBottomAnchor?.constant = 180
self.view.layoutIfNeeded()
}
}
Start with changing your didSelectRowAt function to:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if pickerViewBottomAnchor?.constant != 0 {
pickerViewBottomAnchor?.constant = 0
} else {
pickerViewBottomAnchor?.constant = 180
}
UIView.animate(withDuration: 0.5) {
self.view.layoutIfNeeded()
}
}
Using that, selecting a row in either table should alternately slide the picker view up or down.
If that doesn't do anything, then you (likely) do not have your table views .delegate property set correctly.
If / when that does work, edit the function to only act when you've selected a row on the subTableView

Swift section header uilabels disappear when scrolling uitableview [duplicate]

I am using CollapsibleTableView from here and modified it as per my requirement to achieve collapsible sections. Here is how it looks now.
Since there is a border for my section as per the UI design, I had chosen the section header to be my UI element that holds data in both collapsed and expanded modes.
Reason: I tried but couldn't get it working in this model explained below -
** Have my header elements in section header and details of each item in its cell. By default, the section is in collapsed state. When user taps on the header, the cell is toggled to display. As I said, since there is a border that needs to be shown to the whole section (tapped header and its cell), I chose section header to be my UI element of operation. Here is my code for tableView -
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections.count
}
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + heightOfLabel2!)
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let header = self.tableView.dequeueReusableHeaderFooterViewWithIdentifier("header") as! CollapsibleTableViewHeader
if sections.count == 0 {
self.tableView.userInteractionEnabled = false
header.cornerRadiusView.layer.borderWidth = 0.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
header.amountLabel.hidden = true
header.titleLabel.text = "No_Vouchers".localized()
}
else {
header.amountLabel.hidden = false
header.cornerRadiusView.layer.borderWidth = 1.0
self.tableView.userInteractionEnabled = true
header.titleLabel.text = sections[section].name
header.arrowImage.image = UIImage(named: "voucherDownArrow")
header.setCollapsed(sections[section].collapsed)
let stringRepresentation = sections[section].items.joinWithSeparator(", ")
header.benefitDetailText1.text = stringRepresentation
header.benefitDetailText2.text = sections[section].shortDesc
header.benefitDetailText3.text = sections[section].untilDate
header.section = section
header.delegate = self
if sections[section].collapsed == true {
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
else {
if sections[section].isNearExpiration == true {
header.benefitAlertImage.hidden = false
header.benefitAlertText.hidden = false
}
else {
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
}
if appLanguageDefault == "nl" {
self.totalAmountLabel.text = "€ \(sections[section].totalAvailableBudget)"
}
else {
self.totalAmountLabel.text = "\(sections[section].totalAvailableBudget) €"
}
}
return header
}
Function to toggle collapse/expand -
I am using height values of the "dynamically changing" UILabels inside the section and then using those values to extend the border (using its layoutconstraint).
func toggleSection(header: CollapsibleTableViewHeader, section: Int) {
let collapsed = !sections[section].collapsed
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
// Toggle collapse
sections[section].collapsed = collapsed
header.setCollapsed(collapsed)
// Toggle Alert Labels show and hide
if sections[section].collapsed == true {
header.cornerRadiusViewBtmConstraint.constant = 0.0
header.cornerRadiusViewTopConstraint.constant = 20.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
else {
heightOfLabel2 = header.benefitDetailText2.bounds.size.height
if sections[section].isNearExpiration == true {
header.benefitAlertImage.hidden = false
header.benefitAlertText.hidden = false
header.cornerRadiusViewBtmConstraint.constant = -100.0 - heightOfLabel2!
header.cornerRadiusViewTopConstraint.constant = 10.0
if let noOfDays = sections[section].daysUntilExpiration {
if appLanguageDefault == "nl" {
header.benefitAlertText.text = "(nog \(noOfDays) dagen geldig)"
}
else {
header.benefitAlertText.text = "(valable encore \(noOfDays) jour(s))"
}
}
}
else {
header.cornerRadiusViewBtmConstraint.constant = -80.0 - heightOfLabel2!
header.cornerRadiusViewTopConstraint.constant = 20.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
}
// Adjust the height of the rows inside the section
tableView.beginUpdates()
for i in 0 ..< sections.count {
tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: i, inSection: section)], withRowAnimation: .Automatic)
}
tableView.endUpdates()
}
The problem:
I need to have, few section headers in this table view to be expanded by default on the first launch of the view, based on some conditions. As I am calculating the height of the labels and using the heights to set for the border's top and bottom constraint, it has become difficult to show the expanded section header as per design.
The content comes out of the border since the height of my UILabel is being taken as 21 by default.
UPDATE: The row height changes only after I scroll through the view or when I toggle between collapse/expand
The Question:
How do I calculate the heights of the UILabels present in my Section header by the first time launch of the view? (That means, after my REST call is done, data is fetched and then I need to get the UIlabel height).
Currently, I am using heightOfLabel2 = header.benefitDetailText2.bounds.size.height
(Or)
Is there a better way to achieve this?
Thanks in advance!
Here's what I got working based on my understanding of the overall goals of OP. If I'm misunderstanding, the following is still a working example. Full working project is also linked below.
Goals:
Dynamically sized TableViewCells that are also
Collapsable to show/hide additional details
I tried a number of different ways, this is the only one that I could get working.
Overview
Design makes use of the following:
custom TableViewCells
Autolayout
TableView Automatic Dimension
So if you're not familiar with those (especially Autolayout, might want to review that first.
Dynamic TableViewCells
Interface Builder
Lay out your a prototype cell. It's easiest to increase the row height size. Start simply with just a few elements to make sure you can get it working. (even though adding into Autolayout can be a pain). For example, simply stack two labels vertically, full width of the layout. Make the top label 1 line for the "title" and the second 0 lines for the "details"
Important: To configure Labels and Text Areas to grow to the size of their content, you must set Labels to have 0 lines and Text Areas to not be scrollable. Those are the triggers for fit to contents.
The most important thing is making sure there is a constraint for all four sides of every element. This is essential to get the Automatic Dimensioning working.
CollapsableCell
Next we make a very basic custom class for that table cell prototype. Connect the labels to outlets in the custom cell class. Ex:
class CollapsableCell: UITableViewCell {
#IBOutlet weak var titleLabel: UILabel!
#IBOutlet weak var detailLabel: UILabel!
}
Starting simply with two labels is easiest.
Also make sure that in Interface Builder you set the prototype cell class to CollapsableCell and you give it a reuse ID.
CollapsableCellViewController
On to the ViewController. First the standard things for custom TableViewCells:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "collapsableCell", for: indexPath) as! CollapsableCell
let item = data[indexPath.row]
cell.titleLabel?.text = item.title
cell.detailLabel?.text = item.detail
return cell
}
We've added functions to return the number of rows and to return a cell for a given Row using our custom Cell. Hopefully all straightforward.
Now normally there would be one more function, TableView(heightForRowAt:), that would be required, but don't add that (or take it out if you have it). This is where Auto Dimension comes in. Add the following to viewDidLoad:
override func viewDidLoad() {
...
// settings for dynamic resizing table cells
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 50
...
}
At this point if you set up the detail label to be 0 lines as described above and run the project, you should get cells of different sizes based on the amount of text you're putting in that label. That Dynamic TableViewCells done.
Collapsable Cells
To add collapse/expand functionality, we can just build off the dynamic sizing we have working at this point. Add the following function to the ViewController:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let cell = tableView.cellForRow(at: indexPath) as? CollapsableCell else { return }
let item = data[indexPath.row]
// update fields
cell.detailLabel.text = self.isExpanded[indexPath.row] ? item.detail1 : ""
// update table
tableView.beginUpdates()
tableView.endUpdates()
// toggle hidden status
isExpanded[indexPath.row] = !isExpanded[indexPath.row]
}
Also add 'var isExpanded = Bool' to your ViewController to store the current expanded status for your rows (This could also be class variable in your custom TableViewCell).
Build and click on one of the rows, it should shrink down to only show the title label. And that's the basics.
Sample Project:
A working sample project with a few more fields and a disclosure chevron image is available at github. This also includes a separate view with a demo of a Stackview dynamically resizing based on content.
A Few Notes:
This is all done in normal TableViewCells. I know the OP was using header cells, and while I can't think of a reason why that wouldn't work the same way, there's no need to do it that way.
Adding and removing a subView is the method I originally thought would work best and be most efficient since a view could be loaded from a nib, and even stored ready to be re-added. For some reason I couldn't get this to resize after the subViews were added. I can't think of a reason it wouldn't work, but here is a solution that does.
If I understood your question correctly, what you want to do is to resize your tableHeaderView when you call toggleSection.
Therefore what you need to do for your tableHeaderView to resize is this
// get the headerView
let headerView = self.tableView(self.tableView, viewForHeaderInSection: someSection)
// tell the view that it needs to refresh its layout
headerView?.setNeedsDisplay()
// reload the tableView
tableView.reloadData()
/* or */
// beginUpdates, endUpdates
Basically what you would do is to place the above code snippet inside your function toggleSection(header: CollapsibleTableViewHeader, section: Int)
func toggleSection(header: CollapsibleTableViewHeader, section: Int) {
...
// I'm not sure if this `header` variable is the headerView so I'll just add my code snippet at the bottom
header.setNeedsDisplay()
/* code snippet start */
// get the headerView
let headerView = self.tableView(self.tableView, viewForHeaderInSection: someSection)
// tell the view that it needs to refresh its layout
headerView?.setNeedsDisplay()
/* code snippet end */
// reload the tableView
// Adjust the height of the rows inside the section
tableView.beginUpdates()
// You do not need this
for i in 0 ..< sections.count {
tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: i, inSection: section)], withRowAnimation: .Automatic)
}
// You do not need this
tableView.endUpdates()
}
Explanation: A tableView's headerView/footerView does not update its layout even if you call reloadData() and beginUpdates,endUpdates. You need to tell the view that it needs to update first and then you refresh the tableView
Finally you also need to apply these two codes
func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat {
return estimatedHeight
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return UITableViewAutomaticDimension
}
In this method,
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + heightOfLabel2!)
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
instead of using heightOfLabel2, try implementing the following method to calculate heights specific to each cell(since we know the text to be filled, its font and label width, we can calculate the height of label),
func getHeightForBenefitDetailText2ForIndexPath(indexPath: NSIndexPath)->CGFloat
So your method should look like this,
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + getHeightForBenefitDetailText2ForIndexPath(indexPath))
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
And regarding your problem to expand few cells by for the very first time, make sure you set the collapsed property to true for those cells before reloading the table.
As a performance improvement, you can store the height value calculated for each expanded cell in a dictionary and return the value from the dictionary, to avoid the same calculation again and again.
Hope this helps you. If not, do share a sample project for more insight about your problem.
You can try this for String extension to calculate bounding rect
extension String {
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.height
}
}
Source: Figure out size of UILabel based on String in Swift

Swift - tableView Row height updates only after scrolling or toggle expand/collapse

I am using CollapsibleTableView from here and modified it as per my requirement to achieve collapsible sections. Here is how it looks now.
Since there is a border for my section as per the UI design, I had chosen the section header to be my UI element that holds data in both collapsed and expanded modes.
Reason: I tried but couldn't get it working in this model explained below -
** Have my header elements in section header and details of each item in its cell. By default, the section is in collapsed state. When user taps on the header, the cell is toggled to display. As I said, since there is a border that needs to be shown to the whole section (tapped header and its cell), I chose section header to be my UI element of operation. Here is my code for tableView -
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections.count
}
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + heightOfLabel2!)
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let header = self.tableView.dequeueReusableHeaderFooterViewWithIdentifier("header") as! CollapsibleTableViewHeader
if sections.count == 0 {
self.tableView.userInteractionEnabled = false
header.cornerRadiusView.layer.borderWidth = 0.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
header.amountLabel.hidden = true
header.titleLabel.text = "No_Vouchers".localized()
}
else {
header.amountLabel.hidden = false
header.cornerRadiusView.layer.borderWidth = 1.0
self.tableView.userInteractionEnabled = true
header.titleLabel.text = sections[section].name
header.arrowImage.image = UIImage(named: "voucherDownArrow")
header.setCollapsed(sections[section].collapsed)
let stringRepresentation = sections[section].items.joinWithSeparator(", ")
header.benefitDetailText1.text = stringRepresentation
header.benefitDetailText2.text = sections[section].shortDesc
header.benefitDetailText3.text = sections[section].untilDate
header.section = section
header.delegate = self
if sections[section].collapsed == true {
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
else {
if sections[section].isNearExpiration == true {
header.benefitAlertImage.hidden = false
header.benefitAlertText.hidden = false
}
else {
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
}
if appLanguageDefault == "nl" {
self.totalAmountLabel.text = "€ \(sections[section].totalAvailableBudget)"
}
else {
self.totalAmountLabel.text = "\(sections[section].totalAvailableBudget) €"
}
}
return header
}
Function to toggle collapse/expand -
I am using height values of the "dynamically changing" UILabels inside the section and then using those values to extend the border (using its layoutconstraint).
func toggleSection(header: CollapsibleTableViewHeader, section: Int) {
let collapsed = !sections[section].collapsed
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
// Toggle collapse
sections[section].collapsed = collapsed
header.setCollapsed(collapsed)
// Toggle Alert Labels show and hide
if sections[section].collapsed == true {
header.cornerRadiusViewBtmConstraint.constant = 0.0
header.cornerRadiusViewTopConstraint.constant = 20.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
else {
heightOfLabel2 = header.benefitDetailText2.bounds.size.height
if sections[section].isNearExpiration == true {
header.benefitAlertImage.hidden = false
header.benefitAlertText.hidden = false
header.cornerRadiusViewBtmConstraint.constant = -100.0 - heightOfLabel2!
header.cornerRadiusViewTopConstraint.constant = 10.0
if let noOfDays = sections[section].daysUntilExpiration {
if appLanguageDefault == "nl" {
header.benefitAlertText.text = "(nog \(noOfDays) dagen geldig)"
}
else {
header.benefitAlertText.text = "(valable encore \(noOfDays) jour(s))"
}
}
}
else {
header.cornerRadiusViewBtmConstraint.constant = -80.0 - heightOfLabel2!
header.cornerRadiusViewTopConstraint.constant = 20.0
header.benefitAlertImage.hidden = true
header.benefitAlertText.hidden = true
}
}
// Adjust the height of the rows inside the section
tableView.beginUpdates()
for i in 0 ..< sections.count {
tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: i, inSection: section)], withRowAnimation: .Automatic)
}
tableView.endUpdates()
}
The problem:
I need to have, few section headers in this table view to be expanded by default on the first launch of the view, based on some conditions. As I am calculating the height of the labels and using the heights to set for the border's top and bottom constraint, it has become difficult to show the expanded section header as per design.
The content comes out of the border since the height of my UILabel is being taken as 21 by default.
UPDATE: The row height changes only after I scroll through the view or when I toggle between collapse/expand
The Question:
How do I calculate the heights of the UILabels present in my Section header by the first time launch of the view? (That means, after my REST call is done, data is fetched and then I need to get the UIlabel height).
Currently, I am using heightOfLabel2 = header.benefitDetailText2.bounds.size.height
(Or)
Is there a better way to achieve this?
Thanks in advance!
Here's what I got working based on my understanding of the overall goals of OP. If I'm misunderstanding, the following is still a working example. Full working project is also linked below.
Goals:
Dynamically sized TableViewCells that are also
Collapsable to show/hide additional details
I tried a number of different ways, this is the only one that I could get working.
Overview
Design makes use of the following:
custom TableViewCells
Autolayout
TableView Automatic Dimension
So if you're not familiar with those (especially Autolayout, might want to review that first.
Dynamic TableViewCells
Interface Builder
Lay out your a prototype cell. It's easiest to increase the row height size. Start simply with just a few elements to make sure you can get it working. (even though adding into Autolayout can be a pain). For example, simply stack two labels vertically, full width of the layout. Make the top label 1 line for the "title" and the second 0 lines for the "details"
Important: To configure Labels and Text Areas to grow to the size of their content, you must set Labels to have 0 lines and Text Areas to not be scrollable. Those are the triggers for fit to contents.
The most important thing is making sure there is a constraint for all four sides of every element. This is essential to get the Automatic Dimensioning working.
CollapsableCell
Next we make a very basic custom class for that table cell prototype. Connect the labels to outlets in the custom cell class. Ex:
class CollapsableCell: UITableViewCell {
#IBOutlet weak var titleLabel: UILabel!
#IBOutlet weak var detailLabel: UILabel!
}
Starting simply with two labels is easiest.
Also make sure that in Interface Builder you set the prototype cell class to CollapsableCell and you give it a reuse ID.
CollapsableCellViewController
On to the ViewController. First the standard things for custom TableViewCells:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "collapsableCell", for: indexPath) as! CollapsableCell
let item = data[indexPath.row]
cell.titleLabel?.text = item.title
cell.detailLabel?.text = item.detail
return cell
}
We've added functions to return the number of rows and to return a cell for a given Row using our custom Cell. Hopefully all straightforward.
Now normally there would be one more function, TableView(heightForRowAt:), that would be required, but don't add that (or take it out if you have it). This is where Auto Dimension comes in. Add the following to viewDidLoad:
override func viewDidLoad() {
...
// settings for dynamic resizing table cells
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 50
...
}
At this point if you set up the detail label to be 0 lines as described above and run the project, you should get cells of different sizes based on the amount of text you're putting in that label. That Dynamic TableViewCells done.
Collapsable Cells
To add collapse/expand functionality, we can just build off the dynamic sizing we have working at this point. Add the following function to the ViewController:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let cell = tableView.cellForRow(at: indexPath) as? CollapsableCell else { return }
let item = data[indexPath.row]
// update fields
cell.detailLabel.text = self.isExpanded[indexPath.row] ? item.detail1 : ""
// update table
tableView.beginUpdates()
tableView.endUpdates()
// toggle hidden status
isExpanded[indexPath.row] = !isExpanded[indexPath.row]
}
Also add 'var isExpanded = Bool' to your ViewController to store the current expanded status for your rows (This could also be class variable in your custom TableViewCell).
Build and click on one of the rows, it should shrink down to only show the title label. And that's the basics.
Sample Project:
A working sample project with a few more fields and a disclosure chevron image is available at github. This also includes a separate view with a demo of a Stackview dynamically resizing based on content.
A Few Notes:
This is all done in normal TableViewCells. I know the OP was using header cells, and while I can't think of a reason why that wouldn't work the same way, there's no need to do it that way.
Adding and removing a subView is the method I originally thought would work best and be most efficient since a view could be loaded from a nib, and even stored ready to be re-added. For some reason I couldn't get this to resize after the subViews were added. I can't think of a reason it wouldn't work, but here is a solution that does.
If I understood your question correctly, what you want to do is to resize your tableHeaderView when you call toggleSection.
Therefore what you need to do for your tableHeaderView to resize is this
// get the headerView
let headerView = self.tableView(self.tableView, viewForHeaderInSection: someSection)
// tell the view that it needs to refresh its layout
headerView?.setNeedsDisplay()
// reload the tableView
tableView.reloadData()
/* or */
// beginUpdates, endUpdates
Basically what you would do is to place the above code snippet inside your function toggleSection(header: CollapsibleTableViewHeader, section: Int)
func toggleSection(header: CollapsibleTableViewHeader, section: Int) {
...
// I'm not sure if this `header` variable is the headerView so I'll just add my code snippet at the bottom
header.setNeedsDisplay()
/* code snippet start */
// get the headerView
let headerView = self.tableView(self.tableView, viewForHeaderInSection: someSection)
// tell the view that it needs to refresh its layout
headerView?.setNeedsDisplay()
/* code snippet end */
// reload the tableView
// Adjust the height of the rows inside the section
tableView.beginUpdates()
// You do not need this
for i in 0 ..< sections.count {
tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: i, inSection: section)], withRowAnimation: .Automatic)
}
// You do not need this
tableView.endUpdates()
}
Explanation: A tableView's headerView/footerView does not update its layout even if you call reloadData() and beginUpdates,endUpdates. You need to tell the view that it needs to update first and then you refresh the tableView
Finally you also need to apply these two codes
func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat {
return estimatedHeight
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return UITableViewAutomaticDimension
}
In this method,
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + heightOfLabel2!)
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
instead of using heightOfLabel2, try implementing the following method to calculate heights specific to each cell(since we know the text to be filled, its font and label width, we can calculate the height of label),
func getHeightForBenefitDetailText2ForIndexPath(indexPath: NSIndexPath)->CGFloat
So your method should look like this,
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch indexPath.row {
case 0:
return sections[indexPath.section].collapsed! ? 0 : (100.0 + getHeightForBenefitDetailText2ForIndexPath(indexPath))
case 1:
return 0
case 2:
return 0
default:
return 0
}
}
And regarding your problem to expand few cells by for the very first time, make sure you set the collapsed property to true for those cells before reloading the table.
As a performance improvement, you can store the height value calculated for each expanded cell in a dictionary and return the value from the dictionary, to avoid the same calculation again and again.
Hope this helps you. If not, do share a sample project for more insight about your problem.
You can try this for String extension to calculate bounding rect
extension String {
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.height
}
}
Source: Figure out size of UILabel based on String in Swift

Resources