Angelo Stavrow [dot] Blog

perrewritediary

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Improving the Picker View

Yesterday I got a spike solution to show the UIPickerView showing when weight or volume units were selected in the form detail view. The form view doesn't actually set the ProductList's unitType yet, so today I made some progress toward that.

First, the UI needs to give the user the ability to select the units for the product they're entering. This means populating the picker view's data source with the appropriate units based on what the selection is for unit type (as set by a UISegmentedControl).

This means adding some kind of backing store for some subset of each type of units. I initially did this by adding two Dictionary objects with a String description of the unit as a key, and the symbol for that particular unit type (i.e., UnitMass.kilograms.symbol) as the value, figuring that both can be shown in the picker view, and the symbol alone shown in the form's unit text field.

Then, when the user makes a unit-type selection, a delegate method is called that:

  1. Creates an array where each element is the concatenation of the key and value for each dictionary entries;
  2. Sets that array as the data source for the picker view;
  3. Creates a second array of just the value for each dictionary entry; and
  4. Calls the picker's delegate's didSelectRow: method to set the unit text field's value.

Turns out

Here's a fun thing I forgot about! If you call map() on a Dictionary, the resulting array isn't guaranteed to be in the same order as the input collection.

If that's important, you can use a KeyValuePairs collection, which was renamed from DictionaryLiteral in Swift 5 (here's the proposal: SE-0214).

So here's what these not-really-a-dictionary dictionaries look like:

var pickerView: UIPickerView!
var pickerDataSource = [String]()        // For setting picker options in titleForRow:
var pickerTextFieldOutput = [String]()   // For setting units text field in didSelectRow:

let pickerWeightDataSource: KeyValuePairs = [
    "kilograms": UnitMass.kilograms.symbol,
    "grams": UnitMass.grams.symbol,
    "pounds": UnitMass.pounds.symbol,
    "ounces": UnitMass.ounces.symbol
]
    
 let pickerVolumeDataSource: KeyValuePairs = [
    "liters": UnitVolume.liters.symbol,
    "centiliters": UnitVolume.centiliters.symbol,
    "milliliters": UnitVolume.milliliters.symbol,
    "gallons": UnitVolume.gallons.symbol,
    "quarts": UnitVolume.quarts.symbol,
    "pints": UnitVolume.pints.symbol,
    "fluid ounces": UnitVolume.fluidOunces.symbol
]

Again, this is an ongoing spike solution, so it doesn't take localization into account — beyond the (American-)English descriptive names as keys,  Foundation actually provides for separate US and Imperial volume units, so that you can convert from e.g. UnitVolume.gallons and UnitVolume.imperialGallons — this will be sorted out later.

Here's the functional stuff for setting the picker view's data source and the unit text field's value:

// Swap data source contents based on `UISegmentedControl` selection
func setUnitType(_ sender: UISegmentedControl, target: UITextField) {
    var currentlySelectedRow = pickerView.selectedRow(inComponent: 0)

    switch(sender.selectedSegmentIndex) {
    case 0:
        pickerDataSource = pickerWeightDataSource.map({ key, value in "\(key) (\(value))" })
        pickerTextFieldOutput = pickerWeightDataSource.map({ key, value in "\(value)" })
        pickerView.reloadAllComponents()
    case 2:
        pickerDataSource = pickerVolumeDataSource.map({ key, value in "\(key) (\(value))" })
        pickerTextFieldOutput = pickerVolumeDataSource.map({ key, value in "\(value)" })
        pickerView.reloadAllComponents()
    default:
        pickerDataSource = [""]
        return
    }

    if pickerView.numberOfRows(inComponent: 0) <= currentlySelectedRow {
        currentlySelectedRow = pickerView.numberOfRows(inComponent: 0) - 1
    }
    pickerView.delegate?.pickerView?(self.pickerView, didSelectRow: currentlySelectedRow, inComponent: 0)
    target.becomeFirstResponder()
}

So that's working nicely. Changing selection between weight and volume units works gracefully, and the text field updates as soon as the picker view is shown or changed, so that it's never in a weird state (as can sometimes happen in the current shipping version of Per).

Tomorrow, I'll actually hook this sucker up to set the ProductList's unit type, which should —I think— be all I need to get automatic unit conversion working. Then, I can focus on refactoring this stuff into something a little less hack-y.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Spiking a Picker View

As I mentioned yesterday, I'm a little stuck trying to implement a UIPickerView to let you choose the units for a product. Maybe I was trying a bit too hard to be clever about this, so I'm going to try a spike solution today and see how that works out.

The ProductDetailContentViewController shows a ProductFormDetailView that contains the input fields for entering a new product. When the user taps the input field, I want to show a UIPickerView that presents some options based on the unit type of the list. That unit type is selected when you add the first product via a UISegmentedControl.

I'm going to start by making the ProductDetailContentViewController conform to UIPickerViewDelegate and UIPickerViewDatasource. That means I have to add a couple of methods, so I let Xcode add the stubs:

func numberOfComponents(in pickerView: UIPickerView) -> Int {
    <#code#>
}

func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
    <#code#>
}

I also declare a pickerView and, in the view controller's viewDidLoad() method, have its delegate and data source set to self. For now, the data source will be an array of strings:

var pickerView: UIPickerView!
let pickerDataSource = ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]

override func viewDidLoad() {
    super.viewDidLoad()

    /* --- Some setup for other views --- */

    pickerView = UIPickerView()
    pickerView.delegate = self
    pickerView.dataSource = self
}

Okay, with that, I can fill out those protocol stubs:

func numberOfComponents(in pickerView: UIPickerView) -> Int {
    return 1
}

func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
    return pickerDataSource.count
}

There's an important protocol method missing here, though — the one that shows something in each row of the picker view:

func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
    return pickerDataSource[row] as String
}

That should be enough to get Option 1, Option 2, Option 3, etc., to show in the picker view. Now we just need to add that picker view to the view hierarchy.

Here's where things get a little bit complicated. We only want the picker view to show when the following criteria are met:

  1. The unitType of the ProductList is not dimensionless (i.e., is of UnitMass or UnitVolume)
  2. The user taps on the units field in the ProductDetailFormView

Right now the units field is disabled, so I start by adding logic to the UISegmentedControl's action such that it gets enabled if the user chooses something other than “units”. I also add an showPickerView() delegate method on the view controller that its subviews can call, but all it does for now is print a success message to the console.

Now, I can add logic to the textFieldDidBeginEditing() event listener to show the picker view when someone taps on the units field:

func textFieldDidBeginEditing(_ textField: UITextField) {
    if textField.tag == 101 {
        delegate?.showPickerView()
    }
}

That calls the delegate method successfully, so I add the necessary code to show the picker view:

func showPickerView() {
    view.addSubview(pickerView)
    pickerView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        pickerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0),
        pickerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0),
        pickerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0)
    ])
}

A test in Simulator doesn't show the picker view at first... because it's behind the keyboard. That makes sense.

In the shipping version of Per, I set the keyboard to be the picker view — this avoids any jarring appearing/disappearing of the keyboard as you go from text field to text field. So in fact, that deeply simplifies things: I can pass the calling UITextField into the showPickerView() method and just set its inputView to the picker view:

func showPickerView(_ sender: UITextField) {
    sender.inputView = pickerView
}

And that works like a charm. When the units field is enabled and tapped, I see a picker view. When another text field is tapped, I see the decimal keyboard.

I'll add one final protocol method, so that the text field can be set to the selection in the picker view:

func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
    productDetailFormView.unitsTextField.text = pickerDataSource[row]
}

This works, but is a bad solution: I'm reaching into the subview to directly manipulate one of its controls. The view controller should know nothing about its subviews, because if something changes in the subview, then that could break everything. I added a FIXME here to update this, and it'll probably involve a property observer on something like VolatileFormData to update text fields.

For now, this can wait — I want to continue building off a working solution. Right now the segmented control only shows for the first product you choose, so tomorrow I'll work on making this actually set the ProductList's unitType property so that subsequent products use these units and show the picker view.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Bleh

I was really hoping to wrap up the conversion feature today, but I'm struggling to get a UIPickerView working the way I'd like.

Essentially, I want to be able to set the rows of the picker view based on the UISegmentedControl's selected segment, but for whatever reason I just can't get it to work the way I'd like.

I'm also feeling very distracted today, so I've been having a hard time figuring out just why this isn't working. That's... okay. I mean, it's a little frustrating, but it's okay. I think part of this is coming from trying to force something so that I can write about it, instead of allowing myself the time to go deep and understand what's going on.

This is the last day of the month, but I'm going to continue journaling on a daily basis at least until this functional rewrite is done.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Two days to go

Okay, so tomorrow's the end of the month. I'd like to get to the point where automatic unit conversion is working, but I don't think I'll get there in two hours. One way or the other, I didn't get to the point where the app functionally recreates the features of the currently shipping version of Per, but that's okay! I made a huge amount of progress and I'm really happy about that.

Today, I implemented the UI for picking the unit type when you add your first item to the product list. When the view controller sets the view's delegate, I have a didSet property observer that checks the delegate's numberOfProductItems property and, if there aren't any, inserts a UISegmentedControl where users can choose whether they want weight, volume, or dimensionless units. And it works really well!

Tomorrow, I'll set it up such that choosing a non-dimensionless option in the segmented control lets the user choose a weight or volume unit. Given that tomorrow is Saturday, I may stretch it past the usual hour to get the unit-conversion feature sorted out. There's a lot less complexity this time around for several reasons, so I don't think it'll take too long.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Slow down and sleep

To test what unit type (if any) the list should be limited to, I changed the private _unitType property in the ProductList to

private(set) var unitType: Unit?

This value is set when the first ProductItem is added to the list. Then I can add it as a property with the same access level to the ProductDetailContentViewController:

protocol ProductDetailContentViewControllerDelegate: AnyObject {
    var numberOfProductItems: Int { get }
    // Other protocol stuff
}

This exposes it to the view controller's subviews, but here's something I was perplexed by. In the view controller's viewDidLoad() method, I (via a call to a setupViews() method) instantiate a subview, and set its delegate. But when I was trying to access that delegate from within the subview's initializer, it would return nil. It took me longer than I care to admit to realize that the delegate isn't initialized until after my subview is:

//... other viewDidLoad() stuff in the view controller
productDetailFormView = new ProductDetailFormView(frame: .zero);
productDetailFormView.delegate = self

In retrospect, this is obvious. You've got to wait until the view's initializer does its thing, and then the view's delegate is set — so don't try to access it in the view's initializer.

What's fascinating to me is that —while I know all this— it was incomprehensible why this was happening while I was in the thick of programming, until I gave up and took a break. Why? Probably because I had a lousy night's sleep, and was rushing to get something done early this morning.

The takeaway: get a good night's sleep, and don't rush your work.

Tomorrow, I continue working on this.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

The plan

The shipping version of Per pre-dates the Units and Measurement libraries, so I had to implement all unit conversion carefully myself. Now that it's built into Foundation, I don't have to do that work anymore (thanks Apple!) and instead can focus on the app itself.

Unit conversion itself is handled in the ProductItem model, specifically in the pricePerUnit computed property (as discussed on day 4). Unit-matching enforcement (i.e., so the user doesn't try to compare price-per-pound by price-per-litre) is handled by the ProductList (as discussed on day 5). All this validation is happening at the model layer, so then it's just a question of implementing the UI to do the following:

  1. When adding the first product, an additional bit of UI to select between weight units, volume units, or dimensionless units. In the shipping version, this is a segmented control, and that's probably fine here as well.
  2. Once selected, the units input field (currently a disabled text field) becomes some kind of control where you can choose the units. In the shipping version, this is implemented as a UIPicker, but there's just something about that control I don't like. Maybe some kind of popover?
  3. In any other products added, the UI should default to only showing the the units that match those of the first product. That's easy enough to check for, via the ProductList's _unitType optional property. It's currently marked as private, but that's not really necessary for the getter — only the setter.

Tomorrow, I get cracking on this work!

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Nicer table view cells

Yesterday, I mentioned that each cell should show how much more you're paying over the best-value option. I got that all implemented today, and here's how I did it.

First, I added a property to the ProductList called bestValue that's updated with the lowest pricePerUnit in the array of ProductItems when we add to it. It's a pretty straightforward affair:

mutating func add(_ item: ProductItem, sort: Bool = false) throws {
	// ...the rest of the function
	bestValue = _products.sorted().first?.pricePerUnit ?? 0.0
}

I also added a couple of properties in my ProductListCellView:

  • a valueForMoney string that, when updated, sets a UILabel
  • an isBestValue flag that, when updated, sets the color of the above label's text

I set these in the ProductListContentViewController when adding a new cell, but after checking whether this particular product we're adding to the list is the best-value option:

if (productItem.pricePerUnit == productList.bestValue) {
    cell.valueForMoney = "Best value!"
    cell.isBestValue = true
} else {
    cell.valueForMoney = "You're paying \(Int(100 * (productItem.pricePerUnit - productList.bestValue) / productList.bestValue))% more!"
    cell.isBestValue = false
}

Here's what it looks like now:

"A screen capture of the iPhone Simulator showing a list of products compared by price per unit"

Much better! I'm hard-coding the currency symbol, which is bad — I need to add a proper formatter for the quantity and price values, and handle localization (something that the current shipping version of Per doesn't do, which is also bad). I've opened an issue for this.

A display bug

Something that irks me in the above screenshot is a display bug that I don't quite understand how to resove. You can see empty cells in the table, and that looks ugly. I'd rather they be hidden.

According to all of my research, when the table view is empty, I should be able to hide any empty cells by setting:

productTableView.tableFooterView = UIView(frame: .zero)

That… doesn't work, and I'm not sure why. If you've got any ideas, let me know!

Tomorrow, I'll start work on unit conversion!

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Design Thinking

There's not really much to report today. I spent the day thinking about how to best present information in each ProductListCellView so that it's easily understood; right now ProductItems are sorted in the list by best-value, but there's no indication of how much better the value is. I'm experimenting with some ideas and I think I've got an idea, though that'll mean adding something to the ProductList that stores either the best or worst value for your money, and updates it with each new product that's added.

Then, each cell could show how much worse (or better) it is than that baseline value. So I'll tackle that tomorrow.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Custom Table View Cells

Right now, the ProductListContentViewController is using a very basic UITableViewCell to show basic details of each ProductItem entry. That's fine, but it's going to get messy when that cell gets a custom layout. Better to contain that in a separate view!

So, to begin with, I just wanted to refactor out that code into a new class. Creating one that conformed to UITableViewCell gave me some stubbed methods to add, and I added some properties to the class. First, a UILabel that I can later customize:

private let productDetailLabel: UILabel = {
    let label = UILabel()
    return label
}()

Then a product that, when set, updates the productDetailLabel:

var product: ProductItem {
    didSet {
        let productUnits = product.units?.symbol ?? "unit"
        productDetailLabel.text = "\(product.quantity) \(productUnits)s for \(product.price) costs \(product.pricePerUnit) per \(productUnits)"
    }
}

Right now, this means that I have to initialize the product property with some default value when it's initialized:

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    self.product = ProductItem(price: 1.00, quantity: 1.99, units: nil)
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    addSubview(productDetailLabel)
    setupConstraints()
}

The setupContstraints() method does the same thing that it does in other UIViews — sets the view to not translate its autoresizing mask into constraints, and then activates the constraints I want. Pretty straightforward. Then it's just a question of using this custom cell in ProductListContentViewController instead of the default style:

override func viewDidLoad() {
    // The other viewDidLoad stuff is done here
    // [...]
    // Then the custom cell is registered:
    productTableView.register(ProductListCellView.self, forCellReuseIdentifier: "productListCell")
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let productItem = productList.getItems()[indexPath[1]]
    let cell = productTableView.dequeueReusableCell(withIdentifier: "productListCell", for: indexPath) as! ProductListCellView
    cell.product = productItem
    return cell
}

That's it! Something that I tend to forget when laying things out in code is to set translatesAutoresizingMaskIntoConstraints to false so that everything works as expected, but I'm getting the hang of doing that first in any setupConstraints() method now.

Tomorrow, I'll customize this a bit further with multiple lines.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Marking As Throw

Today's work involves a FIXME for one of my biggest software pet peeves: silent failures. If I try to add a ProductItem with, say, kilogram units to a ProductList of sold-by-volume ProductItems (millilitres, pints, and such), that shouldn't work. The UI will protect against this by pre-selecting available units, but the product list doesn't (and shouldn't) know this — it should protect against this by throwing an error if someone tries this particular operation.

So first, I set up an enum that conforms to Error:

enum ProductListError: Error {
    case units_mismatch
}

Then replaced the current functionality of printing an error to the console:

print("Can't add an item with type \(String(describing: incomingUnitType)) to a list of \(String(describing: _unitType)) items")

with the throwing of an error:

throw ProductListError.units_mismatch

Great! Now that the bad path throws an error, I can handle it at the call sites.

Do Try Catch

To do this is pretty straight forward; just wrap the call to the throwing function in a do-try-catch block. In the ProductListContextViewController, we have an add() method that's called by the ProductDetailContentViewController via the delegation pattern:

func add(_ item: ProductItem) {
    do {
        try contentViewController.productList.add(item, sort: true)
        clearListBarButtonItem?.isEnabled = true
        contentViewController.loadView()
    } catch ProductListError.units_mismatch {
        print("Can't add an item with type \(String(describing: item.units)) to the list.")
        return
    } catch {
        print("Something went wrong adding \(item); abandoning the attempt.")
        return
    }
}

This will try to add the item to the product list; if that fails, it'll catch the error thrown, print an error to the console, and return.

Yes, this is technically still failing silently. But now that it's at the UI layer, I can add a simple alert for now and use that instead of the call to print():

private func displayError(_ message: String) {
    let alert = UIAlertController(title: "Whoops!", message: message, preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default, handler: { _ in
      NSLog(message)
    }))
    self.present(alert, animated: true, completion: nil)
}

Now we're failing verbosely! I'm adding a TODO here to refactor this spike solution for the alert into an extension on UIViewController, because our time for today is up.

Tomorrow, I'm going to start work on a custom table view cell for the product list.

#per #perRewriteDiary #ios

Discuss...