Sunday, July 19, 2015

Self Sizing Static Cells

In my apps I use tables with static cells for things like settings.  One thing I have struggled with is how do you make the cells be the right sizes for the content, and more importantly how do you show and hide those cells?

For example I have one app where I have a cell that shows the current due date.  Clicking the cell opens up a cell below it that shows a date picker.  The user then chooses a new date and presses the row above it to close the date picker cell.

I had been overriding the tableView.heightForRowAtIndexPath method in my view controller and inside that providing explicit height values based on whether the cell is shown or not.

Obviously, returning zero for the height of the cell when it was hidden was easy, but hard coding the height when it wasn't hidden felt wrong.  Additionally, I had to capture a reference to the controls within the cell that was being hidden/shown and hide or show them as well otherwise they would still be visible even though the row's height was 0.

However after watching one of this year's WWDC videos (I don't remember which one, if you are interested let me know and I'll try to find it) I realized, that with auto layout, I should be using the self sizing cells feature implemented in iOS 8.

The WWDC video showed an example for a dynamic table using a text label.  Which makes all the sense in the world for cells that contain multiline labels where the total size isn't known till runtime.  But I was more interested in could this be done with static cell based tables where I had a good idea of the height but didn't want to hard code it.  

I googled this and found various answers from, "It can't be done" to "Here's a hack to make it work".  I didn't like either of those extremes nor any of the answers in between so I decided to build a sample app to test it out.  

In short my results were really good.  I think I have a better understanding of how to make this work and in the end I got rid of a lot of code that I had been using in the past to do this.  

You can divide the process of setting this up into 6 steps.

Step 1 - Set the table view properties

In my viewDidLoad method for the owning controller (a UITableViewController) I had to set two properties:

     tableView.estimatedRowHeight = 68.0  
     tableView.rowHeight = UITableViewAutomaticDimension  

The first one gives an estimated height for each row in the table. The documentation says this is a performance optimization that gives the table "a hint" as to how to size the table's cells.

The second attribute "rowHeight" has to be set to the constant value "UITableViewAutomaticDimension" which means the table view should use the default value for the given dimension.  Yes, I know, that is confusing, but I got that straight from the documentation itself.  I actually learned I should (to support self sizing cells) set this from the WWDC video not from the documentation, go figure.

Step 2 - Track hidden rows

For this step I used similar code to what I had used in past implementations.  For any cell I wanted to hide or show I created a boolean to represent the cell's current state (i.e. hidden or shown):

   var datePickerHidden = true {  
     didSet {  
       if (!datePickerHidden) {  
         datePickerButton.setTitle("Hide Date Picker", forState: UIControlState.Normal)  
       }  
       else {  
         datePickerButton.setTitle("Show Date Picker", forState: UIControlState.Normal)  
       }  
     }  
   }  
   
   var pickerHidden = false {  
     didSet {  
       if (!pickerHidden) {  
         pickerButton.setTitle("Hide Picker", forState: UIControlState.Normal)  
       }  
       else {  
         pickerButton.setTitle("Show Picker", forState: UIControlState.Normal)  
       }  
     }  
   }  
   

In my test app I also created a few buttons to toggle these cells, so I added a "didSet" function to each of these properties to change the title of the button to represent the current state of the cell.

Step 3 - Override tableView.heightForRowAtIndexPath

In my old code I did this as well, but by using the "UITableViewAutomaticDimension" constant for the tableView's rowHeight property, all you are concerned with is returning zero when the cell should be hidden:

   override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {  
     if (indexPath.row == 2 && datePickerHidden) {  
       return 0  
     }  
     if (indexPath.row == 3 && pickerHidden) {  
       return 0  
     }  
     return super.tableView(tableView, heightForRowAtIndexPath: indexPath)  
   }  
   

This cleaned up my old code considerably.  Note the final return statement that calls the super class method should the row at the given index path not be not hidden.  This works for the cells I want to show and hide as well as cells that are always visible.  

In my test app I only had two "hide-able" cells.

One other thing to note is that when you create a static cell base table view using a UITableViewController, you do not have to implement any of the UITableViewDelegate or UITableViewDataSource methods.  If you think about it, it makes sense as you are specifying everything in the storyboard, but I thought that was interesting as I had never thought of it that way.  

But in the case of hiding/showing cells you still have to implement this method.

Step 5 - Create method to toggle a cell's visibility

This is where the meat and potatoes of the code is.  You could code it to just set the values that are needed and update the table but I wanted to animate it a bit.  The animation I chose was as follows:

For the showing animation, the cell is first unhidden and the table is updated which causes the cell to animate to it's correct height when visible.  Then the content of the cell is animated from an alpha of 0 to 1.

For the hiding animation, I basically reverse the animation of showing.  First the content's alpha is changed from 1 to 0 and then the cell's height is animated to 0.

Note: You could choose not to change the alpha of the contained controls, but, in the hiding case, if you choose that option, then the cell will animate it's height to zero while the control stays visible and takes up it's normal intrinsic size.  Only when the animation is over will the control be resize to zero height and disappear from the view.  It looks weird.

I do all of this in the following method:

   func toggleRow(hidden:Bool, view:UIView, cell:UITableViewCell) {  
     if (hidden) {  
       UIView.animateWithDuration(0.25, animations: { () -> Void in  
         // Hide the picker  
         view.alpha = 0  
         }, completion: { (success) -> Void in  
           UIView.animateWithDuration(0.5, animations: { () -> Void in  
             // Now collapse the cell  
             cell.frame.size.height = 0  
             self.tableView.beginUpdates()  
             self.tableView.endUpdates()  
             }, completion: { (success) -> Void in  
               cell.hidden = true  
           })  
       })  
     }  
     else {  
       UIView.animateWithDuration(0.5, animations: { () -> Void in  
         // unhide the cell  
         cell.hidden = false  
         self.tableView.beginUpdates()  
         self.tableView.endUpdates()  
         }, completion: { (success) -> Void in  
           UIView.animateWithDuration(0.25, animations: { () -> Void in  
             // show the cell's contents  
             view.alpha = 1  
           })  
       })  
     }  
   }   
I really wanted to do these animations together (i.e. without cascading) but I found in practice cascading the animations and playing with the timings worked and looked best.

Step 6 - Wire everything up

The final step was to add @IBActions connections to my buttons to toggle the cells.  

Note: One thing (and this is an important thing) I ran into with the pickers, using auto layout, was their constraints. For example I had to pin the leading, trailing and top constraints for the date picker to it's superview as normal.  But for the bottom constraint I had to set it to be greater than or equal to 0.  

Also I had to change the Vertical content compression resistance priority to 740 from it's default of 750 so as to allow the picker to be resized when the cell was resized.

That's basically it.  I have posted the code for the test app on my Github repository here.

As always please feel free to leave me any questions or comments.

No comments:

Post a Comment