Dynamic UIDatePickers in a Table View

Unlike most date pickers found in web applications, iOS UIDatePicker is one of the  bulkiest controls in the standard UIKit library, and among the most difficult ones to integrate in a screen where multiple controls need to be displayed at once. That’s why Apple wisely decided to make them less obnoxious by hiding them until you need them by means of a clever animation.

Usually, Apple iOS applications collecting data or presenting the user with a set of settings, where information needs to be provided by the user, employ a smart animation in which  time or date field are shown as a textfields or labels in a row inside a table view, and then, if you click on that row, an animation will reveal a hidden UIDatePicker below. The illusion works pretty well and the user is intuitively led to choose a date or time associated to that field.

In this post, we are going to learn how to use this technique to show and dismiss a UIDatePicker in a row inside a table when needed.

Our application

Our application will just be a simplification of an application containing the data of the employees of a company. For simplicity’s sake, we will use just a simple UITableView with the data of one person. The user will be able to modify the data from any of the fields. Among the fields, there would be Date fields that, when clicked, will trigger this animated UIDatePicker appearance, and we will take care of dismissing the UIDatePicker when appropriate using a similar animation.

For the model, we will use a very simple model with just one Person class containing several fields of type ModelFieldType (such as name, email…). Two of those fields, “started work date” and “ended work date” will be Date fields, and we want our UIDatePicker to appear in just those two.

The application will have just one screen. Let’s start by creating our project and setting the model, and then the interface elements in IB!

Our Model

Our model will be pretty simple. Just a Person class. Of course, this is a very simplified model just for this example application.

class Person: NSObject {
  // credentials
  var userId: String
  var password: String
 
  // personal information
  var name: String!
  var email: String!
  var startedWorkDate: Date!
  var endedWorkDate: Date!
  var phoneNumber: String!
  var image: UIImage!
  var maritalStatus: String!
 
  // location
  var address: String!
 
  init(userId: String, password: String, name: String!, email: String!, startedWorkDate: Date!, endedWorkDate: Date!, phoneNumber: String!, image: UIImage!, maritalStatus: String!, address: String!) {
    self.userId = userId
    self.password = password
    self.name = name
    self.email = email
    self.startedWorkDate = startedWorkDate
    self.endedWorkDate = endedWorkDate
    self.phoneNumber = phoneNumber
    self.image = image
    self.maritalStatus = maritalStatus
    self.address = address
 }
 
 // MARK: - Hashable/Equatable
  override var hash: Int { return userId.hash }
  override var hashValue: Int { return userId.hashValue }
  override func isEqual(_ object: Any?) -> Bool {
    guard let otherPerson = object as? Person else { return false }
    return otherPerson == self
  }
 
  static var _dateFormatter: DateFormatter?
  fileprivate static var dateFormatter: DateFormatter {
    if (_dateFormatter == nil) {
      _dateFormatter = DateFormatter()
      _dateFormatter!.locale = Locale(identifier: "en_US_POSIX")
      _dateFormatter!.dateFormat = "MM/dd/yyyy"
    }
    return _dateFormatter!
  }
  static func dateFromString(dateString: String) -> Date? {
    return dateFormatter.date(from: dateString)
  }
  static func dateStringFromDate(date: Date) -> String {
    return dateFormatter.string(from: date)
  }
 
  // description
  override var description: String {
     return "Person. Name: \(name), email: \(email), phone: \(phoneNumber), "
  }
}

func ==(lhs: Person, rhs: Person) -> Bool {
  return lhs.userId == rhs.userId
}

As you may see, we defined two Date properties, startedWorkingDate and endedWorkingDate. Those properties will be the ones that will trigger the UIDatePicker animated row. Notice how we defined a dateFormatter and two methods dateStringFromDate and dateFromString, that will help us code the date in a human-readable format in our view and get the date back.

We will also define an enumeration, ModelFieldType, defining the type of fields a person can have, which will help us defining and managing them in the table view later

enum ModelFieldType: String {
  case name = "name"
  case email = "email"
  case startedWorkDate = "started work date"
  case endedWorkDate = "ended work date"
  case phoneNumber = "phone number"
  case image = "image"
  case maritalStatus = "marital status"
  case address = "address"
  case userId = "user Id"
  case password = "password"
}

And we’ll define some methods in Person to get and set the properties corresponding to these fields:

class Person: NSObject {
  // ...

  func valueForField(field: ModelFieldType) -> Any {
    switch field {
      case .name: return name
      case .email: return email
      case .startedWorkDate: return startedWorkDate
      case .endedWorkDate: return endedWorkDate
      case .phoneNumber: return phoneNumber
      case .image: return image
      case .maritalStatus: return maritalStatus
      case .address: return address
      case .userId: return userId
      case .password: return password
    }
  }
 
  func stringValueForField(field: ModelFieldType) -> String {
    if field == .startedWorkDate {
      guard let date = startedWorkDate else { return "-" }
      return Person.dateStringFromDate(date: date)
    } else if field == .endedWorkDate {
      guard let date = endedWorkDate else { return "-" }
      return Person.dateStringFromDate(date: date)
    } else { return valueForField(field: field) as? String ?? "-" }
  }
 
  func setValue(value: Any, forField field: ModelFieldType) {
    switch field {
      case .name: if let name = value as? String { self.name = name }
      case .email: if let email = value as? String { self.email = email }
      case .startedWorkDate:
        if let startedWorkDate = value as? Date { self.startedWorkDate = startedWorkDate }
        else if let swString = value as? String, let swFromString = Person.dateFromString(dateString: swString) { self.startedWorkDate = swFromString }
      // ... rest of fields
    }
  }
}

Again, we are keeping the model simple to be able to focus on the UIDatePicker row management. These functions will allow us to display and modify the fields for a person automatically in our table view.

Our Main Interface Elements

We’ll create a new project using the template “Single View Application”. We will add a label, an image for the person’s avatar image, and a table view for all the information fields that we might want to query and modify.

We will add our outlets for the label, the image view and the table view, and take care of assigning the table view’s delegate and data source to our view controller.

We will also create two UITableViewCell subclasses. One of them will be called TextFieldTableViewCell, and the other one will be called DatePickerTableViewCell. We will create them as subclasses of UITableViewCell, taking care to check the “Also create XIB file” option to design the visual interface in Interface Building.

 

 

Our Text Field Table View Cell

TextFieldTableViewCell will display one field (property) for a person. It will contain a label with the field name and a UITextField with the value. If the field is a text one (like name, email, etc), this textfield will be enabled for input, so you can modify the name directly there. If the field is a date, the textfield will not be enabled for user interaction, and clicking on the row will trigger the UIDatePicker row.

protocol TextFieldTableViewCellDelegate: class { // 1
  func fieldDidBeginEditing(field: ModelFieldType)
  func field(field: ModelFieldType, changedValueTo value: String)
}

class TextFieldTableViewCell: UITableViewCell, UITextFieldDelegate {
  // outlets
  @IBOutlet weak var fieldNameLabel: UILabel!
  @IBOutlet weak var fieldValueTextfield: UITextField!

  // data
  var field: ModelFieldType!
  weak var delegate: TextFieldTableViewCellDelegate?
  
  override func awakeFromNib() {
    super.awakeFromNib()
    fieldValueTextfield.delegate = self
  }
  
  @IBAction func valueChanged(_ sender: UITextField) { // 3
    self.delegate?.field(field: field, changedValueTo: sender.text ?? "")
  }
  
  func configureWithField(field: ModelFieldType, andValue value: String?, editable: Bool) { // 4
    self.field = field
    self.fieldNameLabel.text = self.field.rawValue
    self.fieldValueTextfield.text = value ?? ""
  
    if editable {
      self.fieldValueTextfield.isUserInteractionEnabled = true
      self.selectionStyle = .none
    } else {
      self.fieldValueTextfield.isUserInteractionEnabled = false
      self.selectionStyle = .default
    }
  }
  
  func textFieldDidBeginEditing(_ textField: UITextField) { // 2
    self.delegate?.fieldDidBeginEditing(field: field)
  }
}

This class is pretty straightforward, but has some important elements:

  1. We have a delegate (called TextFieldTableViewCellDelegate). This delegate will allow our table view to get informed of important events such as if the user starts editing a text field or if the value of the field changed.
  2. We will use the fieldDidBeginEditing(field) method of our delegate to inform that the user started typing in a textfield. We will do that by implementing UITextFieldDelegate in our class and settings ourselves as the delegate for the textfield. In the textFieldDidBeginEditing(textfield) method we will call our own delegate’s function fieldDidBeginEditing, so the table view can take appropriate actions, such as dismissing any UIDatePicker when the user is editing a different text field. This is optional but it’s a nice polishing detail.
  3. We will set an IBAction for the UITextField in our class to send a message to our delegate’s field:changedValueTo:, in order to notify the table view when the field value has changed.
  4. The configureWithField:andValue:editable: method will configure our cell with the given field, and the editable property will allow us to identify if this is a date field (in which case, editable will be false) or it’s a text field (editable=true).

Our Date Picker Table View Cell

The DatePickerTableView contains just a UIDatePicker covering all the content view for the cell. We will set a height constraint for our date picker (around 140)  so when we expand the cell containing it, the AutoLayout will adjust the size to exactly match our needs.

Similarly to what we had in TextFieldTableViewCell, we will have a delegate to notify back when the date in the UIDatePicker changes:

protocol DatePickerTableViewCellDelegate: class {
   func dateChangedForField(field: ModelFieldType, toDate date: Date)
}

class DatePickerTableViewCell: UITableViewCell {
   // outlets
   @IBOutlet weak var datePicker: UIDatePicker!
  
   // data
   var field: ModelFieldType!
   weak var delegate: DatePickerTableViewCellDelegate?
  
   override func awakeFromNib() {
     super.awakeFromNib()
   }
  
   func configureWithField(field: ModelFieldType, currentDate: Date?) {
     self.field = field
     self.datePicker.date = currentDate ?? Date()
   }

   @IBAction func datePickerValueChanged(_ sender: Any) {
     self.delegate?.dateChangedForField(field: field, toDate: datePicker.date)
   }
}

The configureWithField:currentDate: configures the date picker for the given field and current date value.

(Short) Theory Behind The Animation

How does the animation work and how does the table view insert and remove this UIDatePicker row? Well, let’s suppose we have a simple table like the one depicted in the figure above. The table view has just one section (section 0) and 8 rows (0-7). Let’s suppose that row number 3 is the date row that will trigger the date picker.

We’ll keep a datePickerIndexPath variable at our view controller for keeping the position of the date picker row if it’s currently visible (or nil otherwise). When the user clicks on the date row, via the didSelectRow:atIndexPath: method, we will calculate the position of the date picker row. As the date picker was not showing already, this row is just the row below our clicked row (that’s index path <0-4>). We will add the date picker there, set our datePickerIndexPath, and then we need to take into account that the rest of the index paths “below” our newly inserted row will shift accordingly, so the index path for the row at <0-6> will become <0-7>.

If the user clicks on the same row, we will dismiss the date picker row, so the index of the cells below will get back to their normal correspondence with the data fields.

One additional situation to consider is the scenario in which the date picker is already being shown. Imagine the situation in the right, and let’s suppose we have another date row in index path <0-5> (in the normal situation, at the left). When the date picker is shown, the index path for this row is <0-6>. So if the user clicks on <0-6>, we need to follow these steps:

  1. We need to identify that the cell selected actually corresponds to the field at index 5, not 6, because the date picker is showing, adding 1 to the count of rows below it.
  2. We need to dismiss the current date picker, which is at index <0-4>.
  3. We will need to add the new date picker below the right cell, and update our datePickerIndexPath variable accordingly.

To implement the changes, we will use UITableView’s “beginUpdates” and “endUpdates” methods. These methods serve as a frame for performing updates to the table view that will result in the modification (addition, substitution, deletion) of cells in an animated fashion. Unlike reloadData(), that simply reloads all the data or reloadRowsAtSections: or similar methods, these modifications will be performed specifically in the selected rows, and the animation of the new row appearing or disappearing we get for free is just what we are after visually.

In between beginUpdates and endUpdates, we will call insertRows and deleteRows to append or delete rows respectively. One important caveat to consider is that UIKit expects you to actually do substantial modifications of the tableView between those two methods, so if you don’t modify any row (due to some inner logic), the application will crash. Thus, make sure you are going to insert, delete or replace something before calling them.

Displaying The Cells

 

Ok, now we are ready to do the actual implementation. Let’s start by defining the tableView:numberOfRowsInSection: and tableView:cellForRowAt: methods for our table view in our ViewController.

// 1
let fields: [ModelFieldType] = [.name, .email, .startedWorkDate, .endedWorkDate, .phoneNumber, .maritalStatus, .address]
let dateFields: [ModelFieldType] = [.startedWorkDate, .endedWorkDate]

// datepicker related data
// 2
var datePickerIndexPath: IndexPath?
var datePickerVisible: Bool { return datePickerIndexPath != nil }

override func viewDidLoad() { // 3
  super.viewDidLoad()

  // load the different cells for our table view
  tableView.register(UINib(nibName: "DatePickerTableViewCell", bundle: nil), forCellReuseIdentifier: "DatePickerTableViewCell")
  tableView.register(UINib(nibName: "TextFieldTableViewCell", bundle: nil), forCellReuseIdentifier: "TextFieldTableViewCell")

  // cell height for table view rows
  tableView.estimatedRowHeight = 80.0
  tableView.rowHeight = UITableViewAutomaticDimension
}

// 4
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  // if our date picker is visible, add one to the list of fields for row count.
  return datePickerVisible ? fields.count + 1 : fields.count
}

// 5
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  // date picker?
  if datePickerVisible && datePickerIndexPath! == indexPath {
    let cell = self.tableView.dequeueReusableCell(withIdentifier: "DatePickerTableViewCell", for: indexPath) as! DatePickerTableViewCell
    cell.delegate = self

    // the field will correspond to the index of the row before this one.
    let field = fields[indexPath.row - 1]
    cell.configureWithField(field: field, currentDate: person.valueForField(field: field) as? Date)
    return cell
  } else {
     let cell = self.tableView.dequeueReusableCell(withIdentifier: "TextFieldTableViewCell", for: indexPath) as! TextFieldTableViewCell
    cell.delegate = self
    let field = calculateFieldForIndexPath(indexPath: indexPath)
    cell.configureWithField(field: field, andValue: person.stringValueForField(field: field), editable: !dateFields.contains(field))
 
    return cell
  }
}

Let’s get through this code step by step:

  1. We define a list of all of our fields, alongside the fields that are “date fields” and will trigger a UIDatePicker row. Your scenario would probably be more complex and involve more elaborate decision making on where to position the date picker fields.
  2. We create a datePickerIndexPath that will be nil if the date picker is not showing, and a calculated variable to check this condition called datePickerVisible.
  3. In viewDidLoad, we will register the two NIBs for the custom UITableViewCell subclasses we defined earlier. We also set some nice values for the estimated and effective row height for our cells.
  4. The tableView:numberOfRowsInSection: method should return the number of fields contained in our fields variable, and add one more in case our date picker cell is showing.
  5. In tableView:cellForRowAt: we will check if the date picker row is showing and the index path we are configuring is the date picker index path. In this case, we dequeue a DatePickerTableViewCell instance, and configure with the proper field (i.e: the field in the row above, indexPath.row – 1. However, if that’s not the case, we need to dequeue a TextFieldTableViewCell cell and configure it with the right field. The method calculateFieldForIndexPath(indexPath) is in charge of getting the right field for a given index path, regardless on whether the date picker row is showing or not and where it’s located.

Let’s have a look at the calculateFieldForIndexPath method:

func calculateFieldForIndexPath(indexPath: IndexPath) -> ModelFieldType {
  if datePickerVisible && datePickerIndexPath!.section == indexPath.section {
    if datePickerIndexPath!.row == indexPath.row { 
      // we are the date picker. Pick the field below me
      return fields[indexPath.row - 1]
    } else if datePickerIndexPath!.row > indexPath.row { 
      // we are "below" the date picker. Just return the field.
      return fields[indexPath.row]
    } else { 
      // we are above the datePicker, so we should substract one from the current row index
      return fields[indexPath.row - 1]
    }
  } else {
     // The date picker is not showing or not in my section, just return the usual field.
     return fields[indexPath.row]
  }
}

This code should be self-explanatory. If the date picker is not showing, we just return the field corresponding to the row of the index path, otherwise, we check if we are above, below or exactly at the date picker index path, and adjust the field index accordingly.

Triggering the Date Picker Row

If you run the application right now, you will see all the fields properly defined in the table view. You can edit the textfields. However, clicking on the date fields will not trigger any interaction. Let’s fix that. First, we

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
   tableView.deselectRow(at: indexPath, animated: true)
   if !datePickerShouldAppearForRowSelectionAtIndexPath(indexPath: indexPath) { // 1
      dismissDatePickerRow()
      return
   }
 
   self.view.endEditing(true)

   tableView.beginUpdates() // 2
   if datePickerVisible { // 3
      // close datepicker
      tableView.deleteRows(at: [datePickerIndexPath!], with: .fade)
      let oldDatePickerIndexPath = datePickerIndexPath!
 
      if datePickerIsRightBelowMe(indexPath: indexPath) { // 3.a
        // just close the datepicker
        self.datePickerIndexPath = nil
      } else { // 3.b
         // open it my new location
         let newRow = oldDatePickerIndexPath.row < indexPath.row ? indexPath.row : indexPath.row + 1
         self.datePickerIndexPath = IndexPath(row: newRow, section: indexPath.section)
         tableView.insertRows(at: [self.datePickerIndexPath!], with: .fade)
      }
   } else { // 4
      self.datePickerIndexPath = IndexPath(row:indexPath.row+1, section: indexPath.section)
      tableView.insertRows(at: [self.datePickerIndexPath!], with: .fade)
   }
   tableView.endUpdates() // 5
}

There’s a lot of logic in here, isn’t it? Let’s analyse it step by step:

  1. First of all, we shouldn’t show any date picker for a non-date field. So if the field we selected is a non-date one, we just dismiss the current date picker row and exit. The datePickerShouldAppearForRowSelectionAtIndexPath takes care of that.
  2. We begin the updates in the table view. Whatever the final result, from this point on we need to add, delete or replace a row.
  3. If the date picker is visible, we need to, first of all, close ir, so we delete its row. We take note of the current datePickerIndexPath in the variable “oldDatePickerIndexPath“, because we are going to recalculate it. Then, we need to differentiate between two cases:
    1. If the date picker is right below this index path, that means that the current field is the one that triggered the date picker row, so if selected again, we should just proceed to dismiss it. We do this by simple setting the datePickerIndexPath to nil.
    2. Otherwise, we need to add the date picker to a new location, just below the current index path. To properly calculate the new row for the date picker, we need to take into account if the new date picker is going to be located above the current one (which is going to dissapear) or below, to adjust its index path accordingly. Then, we just insert the row at the index path with the “fade” animation.
  4. If the date picker is currently not showing, then all we need to do is set the date picker index path to the row below as (row + 1) and insert the row.
  5. Finally, we call endUpdates on the table view to commit the changes.

The dismissDatePickerRow method just removes the current date picker index path in the context of a beginUpdates/endUpdates frame, thus performing the animation that shrinks the date picker row and removes it from the table view:

func dismissDatePickerRow() {
   if !datePickerVisible { return }
   
   tableView.beginUpdates()
   tableView.deleteRows(at: [datePickerIndexPath!], with: .fade)
   datePickerIndexPath = nil
   tableView.endUpdates()
}

This is the datePickerShouldAppearForRowSelectionAtIndexPath method:

func datePickerShouldAppearForRowSelectionAtIndexPath(indexPath: IndexPath) -> Bool {
   let field = calculateFieldForIndexPath(indexPath: indexPath)
   return dateFields.contains(field)
}

Pretty simple, huh? Now, these are two very useful methods: datePickerIsRightAboveMe and datePickerIsRightBelowMe, they just calculate if the index path passed as argument is just above or below the date picker index path:

func datePickerIsRightAboveMe(indexPath: IndexPath) -> Bool {
   if datePickerVisible && datePickerIndexPath!.section == indexPath.section {
      if indexPath.section != datePickerIndexPath!.section { return false }
      else { return indexPath.row == datePickerIndexPath!.row + 1 }
   } else { return false }
}
 
func datePickerIsRightBelowMe(indexPath: IndexPath) -> Bool {
   if datePickerVisible && datePickerIndexPath!.section == indexPath.section {
      if indexPath.section != datePickerIndexPath!.section { return false }
      else { return indexPath.row == datePickerIndexPath!.row - 1 }
   } else { return false }
}

 

Final Details

All that’s left is properly reacting to changes in both text and date fields, by means of the delegates that we defined in our custom UITableViewCell subclasses.

// MARK: - DatePickerTableViewCellDelegate methods
func dateChangedForField(field: ModelFieldType, toDate date: Date) {
   print("Date changed for field \(field) to \(date)")
   person.setValue(value: date, forField: field)
   self.tableView.reloadData()
}
 
// MARK: - TextFieldTableViewCellDelegate
 
func field(field: ModelFieldType, changedValueTo value: String) {
   print("Value changed for field \(field) to \(value)")
   person.setValue(value: value, forField: field)
   self.tableView.reloadData()
}
 
func fieldDidBeginEditing(field: ModelFieldType) {
   dismissDatePickerRow()
}

The fieldDidBeginEditing method will just dismiss the picker row. This is optional, but I feel like it’s appropriate to dismiss the date picker if you are currently editing a different text row.

Conclusions

In this post, we learned how to invoke a date picker cell that will set a date field from a set of data, and will appear and disappear when the date field is selected. This is convenient due to the bulky appearance of the UIDatePicker control. We learned how to manage several date fields and handle the index path for the added date picker row properly.

As always, you can download the full source code for the project from my Github repository.

Do you have any comments? improvements? Suggestions? Let us know in the comments below!

Comments(7)

harkening
July 28, 2017 At 4:01 pm

Well, i can see there is not a single line of code in your provided git repo. That’s sad.

    Ignacio Nieto Carvajal
    July 29, 2017 At 4:01 pm

    Hi there Suhail,
    You are right, my fault. I forgot to do the push. It should be there now, so you can stop being sad! ;)
    Thanks for letting me know.

Tomas Tomasson
November 11, 2017 At 4:01 pm

Any chance of a tutorial (or referral to a tutorial) where this can be done using storyboards? As a newbie i find myself completely lost following this.

    Ignacio Nieto Carvajal
    November 13, 2017 At 4:01 pm

    Hi there Tomas,
    Unfortunately, this cannot be done with Storyboards. As you can see, this tutorial is included in the “Advanced” section of my start here page. And for a good reason. It’s not easy stuff for beginners. You should probably learn a little more and be more comfortable with views before trying this.

    That said, I would really like to improve this tutorial to make it more accessible for everybody, so I would love to hear your feedback or suggestions to make it more clean and easy to understand. Please, don’t hesitate to share your thoughts here. Thanks!

      Darren Gillman
      July 4, 2018 At 4:01 pm

      You can (now?) implement this using IB and prototype cells. It’s pretty much as simple as recreating the cells as TableView prototypes, hooking them up to the classes (after removing the loading from XIB) and IBOulets/Actions.
      If I ever get around to launching my blog there will be an entry on there talking through it, and I’ll drop you link to it.

Justin
November 15, 2017 At 4:01 pm

where does the “person” variable come from. The model is defined “Person”, but in the ‘configure…’ code in tableView you say ‘person.valueForField

    Ignacio Nieto Carvajal
    November 15, 2017 At 4:01 pm

    Hi there Justin. “person” is an instance variable of the UIViewController containing the table view. Basically, what the table view is doing is displaying the fields for that concrete person. Hope it helps!

Leave a Comment

sing in to post your comment or sign-up if you dont have any account.