Creating Custom Table View Cells in Markup

Implementing custom table view cells has traditionally been one of the more challenging aspects of iOS development. In earlier versions of the OS, developers had to calculate cell sizes and position subviews manually, a time-consuming and error-prone process.

Since the introduction of layout constraints and self-sizing cells in iOS 8, the process has become simpler, but MarkupKit makes it even easier by allowing developers to define a cell's structure entirely in markup. Layout views such as LMColumnView and LMRowView can be used to automatically position the cell's subviews and respond to content and orientation changes, leaving the cell class itself responsible simply for providing the cell's behavior.

For example, the following screen shot shows a table view that presents a list of simulated pharmacy search results:

The table view's contents are defined by a JSON document containing the search results. In the example application, these results are static. In an actual application, they would probably be dynamically generated by some kind of web service:

[
    {
      "name": "CVS",
      "address1": "263 Washington Street",
      "city": "Boston",
      "state": "MA",
      "zipCode": "02108",
      "phone": "6177427035",
      "email": "store04@cvs.com",
      "fax": "6177420001",
      "distance": 0.42
    },
    {
      "name": "Walgreens",
      "address1": "70 Summer Street",
      "city": "Boston",
      "state": "MA",
      "zipCode": "02108",
      "phone": "6172657488",
      "email": "store20@walgreens.com",
      "fax": "6177420001",
      "distance": 0.64
    },

    ...
]

The example table view controller loads the simulated result data in viewDidLoad() and stores it in an instance variable named pharmacies. It also sets the estimatedRowHeight property of the table view to 2. Setting this property to a non-zero value is necessary to enable self-sizing cell behavior for a table view:

class CustomCellViewController: UITableViewController {
    var pharmacies: [[String: AnyObject]]!

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Custom Cell View"

        // Configure table view
        tableView.register(PharmacyCell.self, forCellReuseIdentifier: PharmacyCell.self.description())
        tableView.estimatedRowHeight = 2

        // Load pharmacy list from JSON
        let pharmacyListURL = Bundle.main.url(forResource: "pharmacies", withExtension: "json")

        pharmacies = try! JSONSerialization.jsonObject(with: try! Data(contentsOf: pharmacyListURL!)) as! [[String: AnyObject]]
    }

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return pharmacies.count
    }

    ...
}

The custom cell class itself is defined as follows:

class PharmacyCell: LMTableViewCell {
    @IBOutlet var nameLabel: UILabel!
    @IBOutlet var distanceLabel: UILabel!
    @IBOutlet var addressLabel: UILabel!
    @IBOutlet var phoneLabel: UILabel!
    @IBOutlet var faxLabel: UILabel!
    @IBOutlet var emailLabel: UILabel!

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        LMViewBuilder.view(withName: "PharmacyCell", owner: self, root: self)
    }

    required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
    }
}

The class extends LMTableViewCell, a subclass of UITableViewCell that facilitates the definition of custom cell content in markup, and declares a number of outlets for views that will be defined in the markup document. In init(style:reuseIdentifier:), it loads the custom view hiearchy from the document, named PharmacyCell.xml. The init(coder:) method, though unused, is required by Swift. No other logic is necessary.

PharmacyCell.xml is defined as follows:

<LMColumnView spacing="4" layoutMarginBottom="8">
    <LMRowView alignToBaseline="true" spacing="4">
        <UILabel id="nameLabel" weight="1" font="System-Bold 16"/>
        <UILabel id="distanceLabel" font="System 14" textColor="#808080"/>
    </LMRowView>

    <UILabel id="addressLabel" numberOfLines="0" font="System 14"/>

    <LMColumnView spacing="4">
        <LMRowView>
            <UIImageView image="phone_icon"/>
            <UILabel id="phoneLabel" weight="1" font="System 12"/>
        </LMRowView>
        <LMRowView>
            <UIImageView image="fax_icon"/>
            <UILabel id="faxLabel" weight="1" font="System 12"/>
        </LMRowView>
        <LMRowView>
            <UIImageView image="email_icon"/>
            <UILabel id="emailLabel" weight="1" font="System 12"/>
        </LMRowView>
    </LMColumnView>
</LMColumnView>

The root element is an instance of LMColumnView, a layout view that automatically arranges its subviews in a vertical line. The "spacing" attribute specifies that the column view should leave a 4-pixel gap between subviews, and the "layoutMarginBottom" attribute specifies that there should be an 8-pixel gap between the last subview and the bottom of the cell.

The column's first subview is an instance of LMRowView, a layout view that arranges its subviews in a horizontal line. It contains two UILabel instances, one for displaying the name of the pharmacy and another that displays the distance to the pharmacy from the user's current location. The labels will be aligned to baseline and will have a 4-pixel gap between them.

Both labels are assigned "id" values, which map their associated view instances to the similarly-named outlets declared by the document's owner (in this case, the custom cell class). The labels are also styled to appear in 16-point bold and 14-point normal text, respectively, using the current system font.

Another label is created for the pharmacy's mailing address, and another column view containing icons and labels for the pharmacy's phone number, fax number, and email address. These labels are also assigned IDs that associate them with the outlets defined by the cell class. The labels for the phone, fax, and email rows are also assigned a "weight" value of 1, which tells the row view to allocate 100% of its unallocated space to the label; this ensures that the icon will appear on the left and the label will fill the remaining space in the row.

The view controller overrides tableView(_:cellForRowAt:) to produce instances of PharmacyCell for each row in the search results. It retrieves the dictionary instance representing the row from the pharmacies array and populates the cell using the cell's outlets. It then performs some formatting on the raw data retrieved from the JSON document to make the cell's contents more readable:

class CustomCellViewController: UITableViewController {
    ...

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Get pharmacy data
        let index = (indexPath as NSIndexPath).row

        let pharmacy = pharmacies[index]

        // Configure cell with pharmacy data
        let cell = tableView.dequeueReusableCell(withIdentifier: PharmacyCell.self.description()) as! PharmacyCell

        cell.nameLabel.text = String(format: "%d. %@", index + 1, pharmacy["name"] as! String)
        cell.distanceLabel.text = String(format: "%.2f miles", pharmacy["distance"] as! Double)

        cell.addressLabel.text = String(format: "%@\n%@ %@ %@",
            pharmacy["address1"] as! String,
            pharmacy["city"] as! String, pharmacy["state"] as! String,
            pharmacy["zipCode"] as! String)

        let phoneNumberFormatter = PhoneNumberFormatter()

        let phone = pharmacy["phone"] as? String
        cell.phoneLabel.text = (phone == nil) ? nil : phoneNumberFormatter.string(for: phone!)

        let fax = pharmacy["fax"] as? String
        cell.faxLabel.text = (fax == nil) ? nil : phoneNumberFormatter.string(for: fax!)

        cell.emailLabel.text = pharmacy["email"] as? String

        return cell
    }
}

The PhoneNumberFormatter class is defined as follows:

class PhoneNumberFormatter: Formatter {
    override func string(for obj: Any?) -> String? {
        let val = obj as! NSString

        return String(format: "(%@) %@-%@",
            val.substring(with: NSMakeRange(0, 3)),
            val.substring(with: NSMakeRange(3, 3)),
            val.substring(with: NSMakeRange(6, 4))
        )
    }
}

So, using markup to lay out a cell's contents can significantly simplify the process of creating custom table view cells. It also makes it easy to modify the cell's layout as the needs of the application evolve.

The complete source code for this example can be found here:

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s