Episode #206

More TableView Customization

18 minutes
Published on January 28, 2016

This video is only available to subscribers. Get access to this video and 572 others.

In this episode we continue with our Starcraft II Guide application, this time focusing on using a table view to edit a model. We leverage a cell with a text field, a cell to select data from another view controller, and a section that is only editable/reorderable with itself.

Episode Links

Setting up the Table View

This table view will be a mixture of static & dynamic content, so we'll set it all up in code. To make things a little easier, we'll use a Swift enum to represent our different sections.

    enum Sections : Int {
        case Details = 0
        case BuildOrder
        case Actions
        case Count

        var title: String? {
            switch self {
            case .Details: return "Guide Details"
            case .BuildOrder: return "Build Order"
            default: return nil
            }
        }
    }

Here you can see that we have a simple Int enum that shows our sections in the right order. The Count case is there as a neat trick to sum the number of items above it. We also use a title property that will return a title for a given section.

With this in place our table view data source methods are a little cleaner:

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return Sections.Count.rawValue
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        guard let s = Sections(rawValue: section) else { return 0 }

        switch s {
        case .Details: return 2
        case .BuildOrder: return guide.buildOrder.count
        case .Actions: return 1
        default: return 0
        }
    }

    override func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return Sections(rawValue: section)?.title
    }

TextField Cell

To capture the name of the guide, we'll use a TextFieldCell, which is a simple UITableViewCell subclass that adds a textField to the right side. We won't keep a reference to the text field, instead we'll just read the value off of the model and update the model when the text field changes.

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    switch (indexPath.section, indexPath.row) {
    case (Sections.Details.rawValue, 0):
        let cell = TextFieldCell()
        cell.backgroundColor = Theme.Colors.Foreground.color
        cell.label.textColor = Theme.Colors.LightTextColor.color
        cell.label.text = "Name"
        cell.textField.delegate = self
        cell.textField.textColor = Theme.Colors.LightTextColor.color
        cell.textField.text = guide.name
        return cell

    // code omitted
    }
}

// MARK: UITextFieldDelegate

func textFieldDidEndEditing(textField: UITextField) {
    guide.name = textField.text
}

func textFieldShouldReturn(textField: UITextField) -> Bool {
    textField.resignFirstResponder()
    return false
}

Selecting a Race

This one will use a standard cell with the .Value1 style. We'll use a disclosure indicator to indicate that we'll select a value from another view controller. When the user taps on it, we'll present the view controller and handle the selection with a simple callback block.

// cellForRowAtIndexPath:

        case (Sections.Details.rawValue, 1):
            let cell = UITableViewCell(style: .Value1, reuseIdentifier: nil)
            cell.backgroundColor = Theme.Colors.Foreground.color
            cell.textLabel?.text = "Race"
            cell.textLabel?.textColor = Theme.Colors.LightTextColor.color
            cell.textLabel?.font = UIFont.boldSystemFontOfSize(16)
            cell.detailTextLabel?.text = guide.race?.rawValue
            cell.detailTextLabel?.textColor = Theme.Colors.LightTextColor.color
            cell.accessoryType = .DisclosureIndicator
            return cell
// snip


override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
   switch (indexPath.section, indexPath.row) {
   case (Sections.Details.rawValue, 1):
       let raceVC = storyboard!.instantiateViewControllerWithIdentifier("SelectRaceViewController") as! SelectRaceViewController
       raceVC.raceSelectionBlock = { race in
           self.guide.race = race
           self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .None)
           self.navigationController?.popViewControllerAnimated(true)
       }
       self.navigationController?.pushViewController(raceVC, animated: true)
   default: break
}

Selecting a Unit

For the build order section we'll append units as the user selects them.

        case (Sections.BuildOrder.rawValue, let i):
            let unit = guide.buildOrder[i]
            let cell = tableView.dequeueReusableCellWithIdentifier(UnitCell.reuseIdentifier, forIndexPath: indexPath) as! UnitCell
            cell.selectionStyle = .None
            if let imgName = unit.imageName, image = UIImage(named: imgName) {
                cell.imageView?.image = image
            } else {
                cell.imageView?.image = nil
            }
            cell.textLabel?.text = unit.name
            return cell

Here we are using reusable cells since there will be many of these. When you tap on "Add a Unit" we'll call selectUnit():

func selectUnit() {
    if let race = guide.race {
        let unitVC = storyboard!.instantiateViewControllerWithIdentifier("SelectUnitViewController") as! SelectUnitViewController
        unitVC.units = units[race]!
        unitVC.unitSelectionBlock = { unit in
            self.guide.buildOrder.append(unit)
            let rowIndex = self.guide.buildOrder.count - 1
            let indexPath = NSIndexPath(forRow: rowIndex, inSection: 1)
            self.tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
            self.navigationController?.popViewControllerAnimated(true)
        }
        self.navigationController?.pushViewController(unitVC, animated: true)
    } else {
        let alert = UIAlertController(title: "Race not selected", message: "Please select a race first", preferredStyle: .Alert)
        alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil))
        presentViewController(alert, animated: true, completion: nil)
    }
}

Supporting Reorderable Cells just for the Build Order Section

First we need to tell the table view that it is in edit mode. We also need to allow selection during editing so the other table view cells still function:

// viewDidLoad
tableView.editing = true
tableView.allowsSelectionDuringEditing = true

Then we need to tell the table view that we only want editing / reordering for the build order section:

override func tableView(tableView: UITableView, 
  canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
    return indexPath.section == Sections.BuildOrder.rawValue
}

override func tableView(tableView: UITableView,
  canMoveRowAtIndexPath indexPath: NSIndexPath) -> Bool {
    return indexPath.section == Sections.BuildOrder.rawValue
}

override func tableView(tableView: UITableView, 
  moveRowAtIndexPath sourceIndexPath: NSIndexPath,
  toIndexPath destinationIndexPath: NSIndexPath) {
    let unit = guide.buildOrder.removeAtIndex(sourceIndexPath.row)
    guide.buildOrder.insert(unit, atIndex: destinationIndexPath.row)
}   

This works, but it allows us to reorder a unit cell outside of its section! To fix this we need to implement 1 more delegate method:

override func tableView(tableView: UITableView,
    targetIndexPathForMoveFromRowAtIndexPath sourceIndexPath: NSIndexPath,
    toProposedIndexPath proposedDestinationIndexPath: NSIndexPath) -> NSIndexPath {

        if proposedDestinationIndexPath.section != sourceIndexPath.section {

            if proposedDestinationIndexPath.section < sourceIndexPath.section {
                return NSIndexPath(forRow: 0, inSection: sourceIndexPath.section)
            } else {
                let lastRow = self.tableView(tableView, numberOfRowsInSection: sourceIndexPath.section) - 1
                return NSIndexPath(forRow: lastRow, inSection: sourceIndexPath.section)
            }
        }

        return proposedDestinationIndexPath
}

With this in place we're always using an indexPath in the build order section, so it works as you would expect.

Credits

Icons and unit data were obtained from the Starcraft Wikia.