This Old App: Adding User Interfaces

A few months ago, Apple removed my app Interval RunCalc from the App Store for being too old and not updated. In this series of articles I’ll document what I did on rebuilding and improving the app. In the last installment, I built a very flexible UIPickerView into my app for fast but reliable data entry. Now I have to hook up that picker view to my app, and build the first round of user interfaces, along the way finding I’ve painted myself into a corner a few times and made several really silly mistakes.

Back in the first installment in this series, I did a few sketches of what I wanted for the user interface. My final sketch was this:

After I made that sketch, I realized something I forgot. I’ll need a way to change units. I also want a better solution to locking one of the variables to be my result. In sketch above, that feature is scribbled in the margin. To change units, I made a button that displays units. Tapping it would give a selection of units.

I removed two of my five entries on the top, Speed and Repetition. I dropped the repetition feature completely. While writing the model, pace and speed became the same, just with different units. That left the three parts on top, a table for the intervals below it and a toolbar. I cleaned it up to look like this:

I liked the look of this, so I went to Xcode, deleted the controls on the storyboard I was using for testing and designed this:

I kept to a very simple palette. The three rows on top are UIViews, labeled Time, Pace and Speed. Each row has two or three buttons as subviews.

Using auto layout, I aligned the baselines of the of the Units  buttons( the one marked m) and Entry buttons( the one marked Distance), and aligned the Entry vertically to the view, The lock button was pinned up, down and left to 0 and I made the width of the lock button 10% the width of the view. If you need a refresher on auto layout, Check out my book Practical Autolayout for Xcode 8

I can pin all the elements where I want them. This keeps everything modular, and with no extra effort I have a landscape layout, something I never had in the original app.

In a later installment, I’ll use class sizes to make a different layout for iPad.
The lighter center section is a UIView which I use as a container view for my interval table. On the bottom is a space for the toolbar, which is not a real toolbar, but a UIView I’ll once again add buttons to. A UIView with buttons for subviews gives me greater control of the buttons than I get with a toolbar.

Once I have this on a storyboard and all my constraints in place, I add outlets and actions for all the buttons. I’m ready to code.

The Lock Button

I designed the app to input data on two of the three rows, and get a result on the third. To indicate what row will get the result, I’ll use a lock button that toggles between the rows. The selected lock button is bright yellow, the deselected buttons a dark transparent yellow. I’ll add two constants for that

let lockOffColor = UIColor(red: 1.0, green: 1.0, blue: 0.31, alpha: 0.3)
let lockOnColor = UIColor(red: 1.0, green: 1.0, blue: 0.0, alpha: 0.9)

At the press of a lock button, I set the property in the model locked to the locked value, I turn on the touched button to glow yellow, and dim the other two buttons.

@IBAction func lockTime(_ sender: UIButton) {
runStats.locked = .time
timeLock.backgroundColor = lockOnColor
distanceLock.backgroundColor = lockOffColor
paceLock.backgroundColor = lockOffColor
}
@IBAction func lockDistance(_ sender: UIButton) {
runStats.locked = .distance
timeLock.backgroundColor = lockOffColor
distanceLock.backgroundColor = lockOnColor
paceLock.backgroundColor = lockOffColor

}
@IBAction func lockPace(_ sender: UIButton) {
runStats.locked = .pace
timeLock.backgroundColor = lockOffColor
distanceLock.backgroundColor = lockOffColor
paceLock.backgroundColor = lockOnColor

}

This is purely cosmetic. Figuring out what I’ll do with the lock will come later.

Setting the Unit of Measure

Each row has a unit of measure. The time row has no change of units, but the user can change the units for distance and pace. For changeable units, the button’s action will present a UIAlertController as an action sheet with possible units of measure. That will change the title of the button with the units measurement on it. Each unit of measure is a UIAlertAction with a closure. Here the distance action sheet code selecting miles and kilometers :

@IBAction func changeDistanceUnits(_ sender: UIButton) {
let actionSheet = UIAlertController(title: "Distance Units", message: "Select distance units", preferredStyle: .actionSheet)
let kilometers = UIAlertAction(title: "Kilometers", style: .default) { (alertAction) in
self.distanceUnits.setTitle("Km", for: .normal)
}
let miles = UIAlertAction(title: "Miles", style: .default) { (alertAction) in
self.distanceUnits.setTitle("Mi", for: .normal)
}
actionSheet.addAction(kilometers)
actionSheet.addAction(miles)
present(actionSheet, animated: true, completion: nil)
}

I start to get a sinking feeling here, one I had when finishing the picker as well. In version 1.0 the unit conversions were the biggest problem, and I’m beginning to wonder if they will be here too. There is nowhere in the model or controller to select the unit for distance or pace.

Opening the Entry Pickers

I’ll put that aside for the moment. Instead I’ll set up the actions for the three input buttons. In the last installment I went through the testing of a picker, and I copy that code to call the picker view as a modal. This time I’ll use a cross dissolve for the transition:

@IBAction func timerEntry(_ sender: UIButton) {
if runStats.locked != .time{
let vc = storyboard?.instantiateViewController(withIdentifier: "InputController") as! PickerViewController
vc.delegate = self
vc.units = .hoursMinutesSecond
vc.value = 4283.0 // in seconds 3600 + 600 + 60 + 20 + 3 = 1 hr 11min 23sec
vc.modalTransitionStyle = .crossDissolve
present(vc, animated: true, completion: nil)
}
}

I’m leaving the literal data in the action for now, so I’m testing known data. I’ll do similar for the distance and pace entry actions.

The Container View Table

Before I test, I want to add a little bit of code to the interval table view. I’ll implement the intervals after I get the basic app working. I’ll put some dummy data so I can look at the full User Interface. This is in a container view, a great way of getting a table view controller on a part of the superview without it using the complete view. I wrote about this more here if you are interested in the details. In short, I dragged a table view controller to the storyboard, and control-dragged from the UIView to the table view controller, selecting the Embed segue. That creates a relationship between controllers, which you see in the table view controller resizing to the size of the view.

I make a new UIViewController file. I tend not to use the UITableviewController template, since it has way too much junk in it. Instead I change the subclass UIViewController to UITableViewController.

class IntervalTableViewController: UITableViewController

Once I have this file made, I’ll use the identity inspector to set it as the class of the table view controller.

For the table view data sources I give one section and ten rows.

override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}

For the cells I print the row number with alternating colors.

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let row = indexPath.row
if (row % 2) == 0{
cell.backgroundColor = UIColor.blue
} else {
cell.backgroundColor = UIColor.white
}
cell.textLabel?.text = " Row:\(row)"
return cell
}

I’ll add a title to the one section I have.

override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return "Intervals"
}

The table will show up for placement, but I’ll do nothing with it in this installment. I want calculations working first.

The First Iteration Test

I’m ready for my first test. Running the app gets me this:

The table is in place and the app looks nice and clean. Tapping the m/s for meters per second, opens up a action sheet.

Tapping Miles per Hour gets me mile per hour

If I tap Time, nothing happens. That’s the lock in action. But If I tap speed, the pickers show, with the correct value.

I lock speed

and try the time:

I can send data to the pickers.

 

Matching Colors in the Picker

I’m not happy about the colors. The pickers don’t match the rest of the app. I’d like the pickers to reflect the colors of the RootViewController without me playing around too much. If I change the root’s colors later on, the colors should still match.

I set up PickerViewController for background color property observers.

var backgroundColor = UIColor.darkGray{
didSet{
view.backgroundColor = backgroundColor
}
}
var toolBarColor = UIColor.lightGray{
didSet{
toolBar.backgroundColor = toolBarColor
}
}

In RootViewController, I assign the view’s background color to the PickerViewController’s background color.

@IBAction func timerEntry(_ sender: UIButton) {
if runStats.locked != .time{
let vc = storyboard?.instantiateViewController(withIdentifier: "InputController") as! PickerViewController
vc.units = .hoursMinutesSecond
vc.value = 4283.0 // in seconds 3600 + 600 + 60 + 20 + 3 = 1 hr 11min 23sec
vc.modalTransitionStyle = .crossDissolve
present(vc, animated: true, completion: nil)
vc.backgroundColor = view.backgroundColor!
}
}

When I change the property backgroundColor in the picker it should change the color in the picker. I try it and still get bright backgrounds, not dark ones. I forgot to take out test code. In the PickerViewController class, I have these three functions:

func distance(){
titleLabel.text = "Distance"
backgroundColor = UIColor.cyan
}
func paceOrSpeed(){
titleLabel.text = "Pace / Speed"
backgroundColor = UIColor.yellow
}
func time(){
titleLabel.text = "Time"
backgroundColor = UIColor.green
}

I comment out those background colors and run again. The background appears.

I see two more changes. I want to change the toolbar color to the button’s color. If I change the toolbar at top to the same color as the button’s background with this:

vc.toolBarColor = sender.backgroundColor!

It fails with an unexpectedly found nil while unwrapping an Optional value error. I know what’s wrong almost immediately. This is one of those silly mistakes, but one that is so subtle it could take a long time to find it. I’m relieved I found it early. The background isn’t in the button, but in the superview of the button. The button background is transparent, hence the nil. It should be this:

vc.toolBarColor = (sender.superview?.backgroundColor!)!

That works, but the result is worse for the text. Black on dark just doesn’t work.

I’m a little nervous about all those optionals and want a function to set the the text color. I add this to clean it all up.

func colors(picker:PickerViewController, from button:UIButton){
    if let backgroundColor = view.backgroundColor{
    picker.backgroundColor = backgroundColor
}
     if let toolBarColor = button.superview?.backgroundColor{
     picker.toolBarColor = toolBarColor
}
}

I replace the lines changing color in the actions with the colors method.

colors(picker: vc, from: sender)

My foreground text solution will go in the colors function. The color I’ll choose is the same as a title color, so I can start like this:

if let titleColor = button.titleColor(for: .normal){
}

Then I get stuck. I forgot to add a foreground color property to PickerViewController. The UIPickerView in the controller also does not have a foreground color property to set. Originally, I used the easiest of the UIPickerViewDatasource methods to display a picker component pickerView:titleForRow:forComponent. That returns a string. There’s a delegate method to return a UIView, such as a UILabel in whatever color or font I want:

func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
    let label = UILabel()
    label.font = UIFont.preferredFont(forTextStyle: .title1)
    label.textColor = textColor
    switch components[component]{
    case .ten:
        label.text = String(format: "%01i", row)
    case .six:
        label.text = String(format: "%01i", row)
    case .colon:
        label.text = ":"
    case .decimal:
        label.text = "."
    }
    return label
}

I used a property textColor which I have yet to define. I’ll use a property observer to change all the other text on the picker controller to this color.

var textColor = UIColor.black{
    didSet{
        backButton.setTitleColor(textColor, for: .normal)
        doneButton.setTitleColor(textColor, for: .normal)
        titleLabel.textColor = textColor
        unitsOfMeasureLabel.textColor = textColor
}
}

Back in RootViewController, the colors method changes to:

func colors(picker:PickerViewController, from button:UIButton){
     if let backgroundColor = view.backgroundColor{
         picker.backgroundColor = backgroundColor
}
     if let toolBarColor = button.superview?.backgroundColor{
         picker.toolBarColor = toolBarColor
}
     if let titleColor = button.titleColor(for: .normal){
         picker.textColor = titleColor

}
}

Now I reflect the colors in my buttons in the picker, giving a consistent and readable look:

The title should read Distance, not 583.0 . That’s a testing element I left in the title, which I comment out.

func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
updatePickerValue()
//titleLabel.text = "\(value)" //for testing only
}

Delegation From the Picker

I’ll send data from the picker to the root via delegation. I’ll add to PickerViewController.swift a protocol for a delegate:

protocol PickerViewControllerDelegate{
func pickerDidFinish(value:Double, controller:PickerViewController)
}

I’ll go through all the normal steps for making a delegate. If you don’t know those I’d suggest my LinkedInLearning course on delegation and datasources. The last of those steps is adding the delegate method to RootViewController.

func pickerDidFinish(value: Double, controller: PickerViewController) {
}

I’ve been ignoring an important question: value is a Double, but what does it measure? What’s the units? How do I reflect that in the user interface?

I designed PickerViewController with an enum for that.

enum Units {
        case meters,kilometers,miles //distance
        case milesPerHour,minutesSecondsPerMile,kilometersPerHour // speed and pace
        case hoursMinutesSecond //time
    }
}

I set up a switch clause, and assign value to the right place in the right unit system…well almost.

func pickerDidFinish(value: Double, controller: PickerViewController) {
    switch controller.units {
    case .meters:
        runStats.distance.meters(value)
    case .kilometers:
        runStats.distance.kilometers(value)
    case .miles:
        runStats.distance.miles(value)
    case .hoursMinutesSecond:
        runStats.time.seconds(value)
    case .kilometersPerHour:
        runStats.pace.kilometersPerHour(value)
    case .milesPerHour:
        runStats.pace.milesPerHour(value)
    case .minutesSecondsPerMile:
        //ummmmm.......not there!!!
    }
}

I need one more conversion setter I didn’t originally have in PaceStats. for .minutesSecondsPerMile going to the picker, I have seconds per mile coming back out, but no method to change and store that as meters per second. I did the calculations and included it as part of the minutes and seconds getter:

public func minutes(_ minutes:Int,seconds:Int){
let secondsPerMile = Double(60 * minutes + seconds)
paceStat = 1609.344 / secondsPerMile
}

I can break this up to

func secondsPerMile(_ secondsPerMile:Double){
paceStat = 1609.344 / secondsPerMile
}
public func minutes(_ minutes:Int,seconds:Int){
secondsPerMile(Double(60 * minutes + seconds))
}

then finish the delegate method with

case .minutesSecondsPerMile:
runStats.pace.secondsPerMile(value)

Display Updates

I returned data from the picker and changed the model. Next I’ll update the root view’s display with new values. I’ll start a new method and add this for the time using the hourMinutesSeconds function of TimeStat

func updateDisplay(){
//time
    let timeTitle = "Time: " + runStats.time.hoursMinutesSeconds()
    timeEntry.setTitle(timeTitle, for: .normal)
}

For distance, I have meters, kilometers and miles. Which one do I display? I have no idea and the model doesn’t either. I’ll have to add tracking of this. I can track this in the model or in the controller. I go for the model. I’ll add an enumeration, a property using that enumeration, and a method that returns a formatted string based on that property.

I’ll try this first with the simpler DistanceStat. The enum for distance DisplayUnits would be kilometers and miles, which I add to a property displayUnits.

enum DisplayUnits {
    case kilometers,miles
}
var displayUnits:DisplayUnits = .miles

To my getters, I add the method

func displayString()->String{
    let string = "Distance: %7.1"
    switch displayUnits {
    case .miles:
         return String(format:string, miles())
    case .kilometers:
        return String(format:string, kilometers())
}

}

With a model that tracks the units I’m using, I set displayUnits in the action sheets’ alert actions I set up earlier. I’ll also update the display when I change units. For distance’s kilometer Alert Action it would look like this:

let kilometers = UIAlertAction(title: "Kilometers", style: .default) { (alertAction) in
self.distanceUnits.setTitle("Km", for: .normal)
self.runStats.distance.displayUnits = .kilometers
self.updateDisplay()
}

Miles would follow the same pattern. I‘ll set the distanceEntry button title in updateDisplay to

distanceEntry.setTitle(runStats.distance.displayString(), for: .normal)

In distanceEntry, I’ll set vc to 5 kilometers as the initial value. I’ll hit run and get 5 kilometers.

I’ll change that to 8 and press Done. Nothing happens. That’s because I forgot to add update to the delegate method. I did add it to the units, so I’ll change to Kilometers and get 7, which is the wrong answer.

I change to Miles and also get 7.

Checking my code I find a seriously stupid error.

let string = "Distance: %7.1”

I forgot an f. It should be %7.1f. I add updateDisplay to the delegate method too. I try again. And this time it works. For kilometers I get 5.0 and for miles 3.11.

I’ll do the same thing for the pace, now that I know this works. The only variation is distinguishing between speed and pace, which I do in updateDisplay.

var paceTitle = runStats.distance.displayString()
if runStats.pace.displayUnits == .minutesSecondsPerMile{
    paceTitle = "Pace: " + paceTitle
} else {
    paceTitle = "Speed: " + paceTitle
}
paceEntry.setTitle(paceTitle, for: .normal)

I try that out in the simulator, and it fails, reading zero. The picker reads correctly as 11:23 min/mi and the speed/pace label changes correctly. Changing the units always reads zero. I add to the delegate method a print(value) to check if the value gets there, and it does. Still in the delegate method I try to see if the displayString() works right, and it does. The problem is in updateDisplay, and once again it’s a typo. I had runStats.distance instead of runStats.pace.

I try it one more time, And this time it works.

I’ve connected all the user interface elements to the controller. Along the way, I made several silly mistakes, which can and will happen to anyone writing code. Its silly mistakes like these that make the most bugs. I also gained a greater appreciation for enum and switch, which are doing an incredibly good job of keeping my units straight. I have a consistent user interface, one that I find streamlined and elegant, at least compared to version 1.0. I have a lot to go though. In the next installment, I’ll hookup the model to the entry buttons, and start calculating run stats.

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