16

I have been trying to implement a feature in my app so that when a user taps a cell in my table view, the cell expands downwards to reveal notes. I have found plenty of examples of this in Objective-C but I am yet to find any for Swift.

This example seems perfect: Accordion table cell - How to dynamically expand/contract uitableviewcell?

I had an attempt at translating it to Swift:

var selectedRowIndex = NSIndexPath()
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    selectedRowIndex = indexPath
    tableView.beginUpdates()
    tableView.endUpdates()
}

override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    if selectedRowIndex == selectedRowIndex.row && indexPath.row == selectedRowIndex.row {
        return 100
    }
    return 70
}

However this just seems to crash the app.

Any ideas?

Edit:

Here is my cellForRowAtIndexPath code:

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    var cell:CustomTransactionTableViewCell = self.tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as CustomTransactionTableViewCell

    cell.selectionStyle = UITableViewCellSelectionStyle.None

    if tableView == self.searchDisplayController?.searchResultsTableView {
        cell.paymentNameLabel.text = (searchResults.objectAtIndex(indexPath.row)) as? String
        //println(searchResults.objectAtIndex(indexPath.row))
        var indexValue = names.indexOfObject(searchResults.objectAtIndex(indexPath.row))
        cell.costLabel.text = (values.objectAtIndex(indexValue)) as? String
        cell.dateLabel.text = (dates.objectAtIndex(indexValue)) as? String

        if images.objectAtIndex(indexValue) as NSObject == 0 {
            cell.paymentArrowImage.hidden = false
            cell.creditArrowImage.hidden = true
        } else if images.objectAtIndex(indexValue) as NSObject == 1 {
            cell.creditArrowImage.hidden = false
            cell.paymentArrowImage.hidden = true
        }
    } else {
        cell.paymentNameLabel.text = (names.objectAtIndex(indexPath.row)) as? String
        cell.costLabel.text = (values.objectAtIndex(indexPath.row)) as? String
        cell.dateLabel.text = (dates.objectAtIndex(indexPath.row)) as? String

        if images.objectAtIndex(indexPath.row) as NSObject == 0 {
            cell.paymentArrowImage.hidden = false
            cell.creditArrowImage.hidden = true
        } else if images.objectAtIndex(indexPath.row) as NSObject == 1 {
            cell.creditArrowImage.hidden = false
            cell.paymentArrowImage.hidden = true
        }
    }
    return cell
}

Here are the outlet settings:

enter image description here

Community
  • 1
  • 1
user3746428
  • 11,047
  • 20
  • 81
  • 137
  • 2
    This part doesn't make sense to me: `if selectedRowIndex == selectedRowIndex.row` ? – zisoft Oct 06 '14 at 13:48
  • Try to create `NSIndexPath` bit differentyl `var selectedRow = NSIndexPath(forRow: 0, inSection: 0)` – Kirsteins Oct 06 '14 at 14:33
  • @zisoft Well the example I was basing that off has `selectedRowIndex && indexPath.row == selectedRowIndex.row` but if I try to use that then I get the error: Type NSIndexPath does not conform to protocol 'Boolean Type' – user3746428 Oct 06 '14 at 16:04
  • @Kirsteins While that stops the app from crashing, the cell doesn't expand when tapped. – user3746428 Oct 06 '14 at 16:05

5 Answers5

18

It took me quite a lot of hours to get this to work. Below is how I solved it.

PS: the problem with @rdelmar's code is that he assumes you only have one section in your table, so he's only comparing the indexPath.row. If you have more than one section (or if you want to already account for expanding the code later) you should compare the whole index, like so:

1) You need a variable to tell which row is selected. I see you already did that, but you'll need to return the variable to a consistent "nothing selected" state (for when the user closes all cells). I believe the best way to do this is via an optional:

var selectedIndexPath: NSIndexPath? = nil

2) You need to identify when the user selects a cell. didSelectRowAtIndexPath is the obvious choice. You need to account for three possible outcomes:

  1. the user is tapping on a cell and another cell is expanded
  2. the user is tapping on a cell and no cell is expanded
  3. the user is tapping on a cell that is already expanded

For each case we check if the selectedIndexPath is equal to nil (no cell expanded), equal to the indexPath of the tapped row (same cell already expanded) or different from the indexPath (another cell is expanded). We adjust the selectedIndexPath accordingly. This variable will be used to check the right rowHeight for each row. You mentioned in comments that didSelectRowAtIndexPath "didn't seem to be called". Are you using a println() and checking the console to see if it was called? I included one in the code below.

PS: this doesn't work using tableView.rowHeight because, apparently, rowHeight is checked only once by Swift before updating ALL rows in the tableView.

Last but not least, I use reloadRowsAtIndexPath to reload only the needed rows. But, also, because I know it will redraw the table, relayout when necessary and even animate the changes. Note the [indexPath] is between brackets because this method asks for an Array of NSIndexPath:

override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        println("didSelectRowAtIndexPath was called")
        var cell = tableView.cellForRowAtIndexPath(indexPath) as! MyCustomTableViewCell
        switch selectedIndexPath {
        case nil:
            selectedIndexPath = indexPath
        default:
            if selectedIndexPath! == indexPath {
                selectedIndexPath = nil
            } else {
                selectedIndexPath = indexPath
            }
        }
        tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Automatic)
}

3) Third and final step, Swift needs to know when to pass each value to the cell height. We do a similar check here, with if/else. I know you can made the code much shorter, but I'm typing everything out so other people can understand it easily, too:

override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
        let smallHeight: CGFloat = 70.0
        let expandedHeight: CGFloat = 100.0
        let ip = indexPath
        if selectedIndexPath != nil {
            if ip == selectedIndexPath! {
                return expandedHeight
            } else {
                return smallHeight
            }
        } else {
            return smallHeight
        }
    }

Now, some notes on your code which might be the cause of your problems, if the above doesn't solve it:

var cell:CustomTransactionTableViewCell = self.tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as CustomTransactionTableViewCell

I don't know if that's the problem, but self shouldn't be necessary, since you're probably putting this code in your (Custom)TableViewController. Also, instead of specifying your variable type, you can trust Swift's inference if you correctly force-cast the cell from the dequeue. That force casting is the as! in the code below:

var cell = tableView.dequeueReusableCellWithIdentifier("CellIdentifier" forIndexPath: indexPath) as! CustomTransactionTableViewCell

However, you ABSOLUTELY need to set that identifier. Go to your storyboard, select the tableView that has the cell you need, for the subclass of TableViewCell you need (probably CustomTransactionTableViewCell, in your case). Now select the cell in the TableView (check that you selected the right element. It's best to open the document outline via Editor > Show Document Outline). With the cell selected, go to the Attributes Inspector on the right and type in the Identifier name.

You can also try commenting out the cell.selectionStyle = UITableViewCellSelectionStyle.None to check if that's blocking the selection in any way (this way the cells will change color when tapped if they become selected).

Good Luck, mate.

Lucas Cerro
  • 4,484
  • 2
  • 16
  • 8
  • Wouldn't this cause issues with cells that use tableView.rowHeight = UITableViewAutomaticDimension? – cyril May 24 '15 at 08:17
  • I *believe* that rowHeight will override the UITableViewAutomaticDimension, but I'm not sure. But my answer above assumes you have fixed heights for the rows (both in the expanded and contracted states), so the UITableViewAutomaticDimension should not have to be used here. – Lucas Cerro May 24 '15 at 17:30
  • 1
    I tried your method with UITableViewAutomaticDimension and returning to normal size works fine but not the expansion. I'll update you when I figure it out – cyril May 24 '15 at 21:48
  • @cyril Were you able to figure out the expansion? – aejhyun Apr 22 '16 at 13:25
  • @Jae haven't looked at it since :/ – cyril Apr 26 '16 at 02:41
7

The first comparison in your if statement can never be true because you're comparing an indexPath to an integer. You should also initialize the selectedRowIndex variable with a row value that can't be in the table, like -1, so nothing will be expanded when the table first loads.

var selectedRowIndex: NSIndexPath = NSIndexPath(forRow: -1, inSection: 0)

override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    if indexPath.row == selectedRowIndex.row {
        return 100
    }
    return 70
}

Swift 4.2 var selectedRowIndex: NSIndexPath = NSIndexPath(row: -1, section: 0)

Rmalmoe
  • 993
  • 11
  • 13
rdelmar
  • 103,982
  • 12
  • 207
  • 218
  • 1
    Thanks for your help and the explanation. The cell still doesn't seem to expand though when tapped. I'll update my question with my cellForRowAtIndex code so you can see if there is an obvious issue. – user3746428 Oct 06 '14 at 20:07
  • @user3746428, I don't see anything in your cellForRowAtIndexPath method that would cause the cell to not expand. Put a log in right above "return 100" and see if that clause is ever executed. If not, put a log in didSelectRowAtIndexPath to make sure it's being executed. – rdelmar Oct 06 '14 at 21:11
  • Strangely, the code does execute and work if I perform a search and then tap one of the results, but it does not execute if I just tap one of the cells normally. – user3746428 Oct 06 '14 at 21:26
  • @user3746428 Do you have if-else clauses in didSelectRowAtIndexPath and/or heightForRowAtIndexPath to distinguish between the two table views? – rdelmar Oct 06 '14 at 22:07
  • Nope. The code is exactly as it is in my original question. – user3746428 Oct 06 '14 at 22:08
  • @user3746428, when you tap on a cell in the main table view, is didSelectRowAtIndexPath called? – rdelmar Oct 06 '14 at 22:19
  • No, it doesn't seem to be. – user3746428 Oct 06 '14 at 23:29
  • @user3746428, try touching all over different areas of the cell. The only thing I can think of is that some view is getting the touches instead of the cell itself. – rdelmar Oct 07 '14 at 01:19
  • I just gave that a try, but there is no change no matter where I tap. There are three labels and a image view on the cell, but they don't cover the entire thing anyway. – user3746428 Oct 07 '14 at 09:01
  • @user3746428, I guess I didn't ask the obvious question. Have you set the delegate for the main table view? – rdelmar Oct 07 '14 at 14:56
  • Yeah, all of that should be fine. I've edited my question to include the outlets, but it all looks ok to me. – user3746428 Oct 07 '14 at 21:59
  • @user3746428, I'm out of ideas then. I tested the code I posted, and it works fine for me. Unless you can post your project somewhere so I can look at it, I can't think of anything else to help. – rdelmar Oct 07 '14 at 22:16
  • I was discussing this a while back and someone suggested this - `There is a tableview parameter passed in to cellForRowAtIndexPath - this is the tableview you are currently operating on, so you should call var cell:CustomTransactionTableViewCell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as CustomTransactionTableViewCell` Could this be the issue? I tried using that code but it gives me the error - `unable to dequeue a cell with identifier Cell - must register a nib or a class for the identifier or connect a prototype cell in a storyboard` – user3746428 Oct 07 '14 at 22:42
  • But if that isn't an obvious solution then I may go ahead and send you the project at some point if I am unable to fix it. Thanks. – user3746428 Oct 07 '14 at 22:45
  • @user3746428, That should be the right way to do it. – rdelmar Oct 07 '14 at 22:47
  • Ah. It seems to crash with that error that I mentioned when I tap a key to perform a search, but it still doesn't fix the issue with the cells not expanding anyway, so I suppose I'll just leave it as it is. – user3746428 Oct 07 '14 at 22:51
6

A different approach would be to push a new view controller within the navigation stack and use the transition for the expanding effect. The benefits would be SoC (separation of concerns). Example Swift 2.0 projects for both patterns.

Justin Fischer
  • 163
  • 2
  • 8
6

I suggest solving this with modyfing height layout constraint

class ExpandableCell: UITableViewCell {

@IBOutlet weak var img: UIImageView!


@IBOutlet weak var imgHeightConstraint: NSLayoutConstraint!


var isExpanded:Bool = false
    {
    didSet
    {
        if !isExpanded {
            self.imgHeightConstraint.constant = 0.0

        } else {
            self.imgHeightConstraint.constant = 128.0
        }
    }
}

}

Then, inside ViewController:

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
    super.viewDidLoad()
    self.tableView.delegate = self
    self.tableView.dataSource = self
    self.tableView.estimatedRowHeight = 2.0
    self.tableView.rowHeight = UITableViewAutomaticDimension
    self.tableView.tableFooterView = UIView()
}


// TableView DataSource methods
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return 3
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell:ExpandableCell = tableView.dequeueReusableCell(withIdentifier: "ExpandableCell") as! ExpandableCell
    cell.img.image = UIImage(named: indexPath.row.description)
    cell.isExpanded = false
    return cell
}

// TableView Delegate methods
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

    guard let cell = tableView.cellForRow(at: indexPath) as? ExpandableCell
        else { return }

        UIView.animate(withDuration: 0.3, animations: {
            tableView.beginUpdates()
            cell.isExpanded = !cell.isExpanded
            tableView.scrollToRow(at: indexPath, at: UITableViewScrollPosition.top, animated: true)
            tableView.endUpdates()

        })

    }




func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
    guard let cell = tableView.cellForRow(at: indexPath) as? ExpandableCell
        else { return }
    UIView.animate(withDuration: 0.3, animations: {
        tableView.beginUpdates()
        cell.isExpanded = false
        tableView.endUpdates()
    })
}
}

Full tutorial available here

theDC
  • 6,364
  • 10
  • 56
  • 98
0

After getting the index path in didSelectRowAtIndexPath just reload the cell with following method reloadCellsAtIndexpath and in heightForRowAtIndexPathMethod check following condition

if selectedIndexPath != nil && selectedIndexPath == indexPath {    
       return yourExpandedCellHieght
}
saurabh
  • 6,687
  • 7
  • 42
  • 63
Abhishek
  • 509
  • 3
  • 12