Stupid Swift Tricks #5
Note: This is a very old post that is now out-of-date. For example, self.dynamicType is now type(of: self), there are more-modern approaches to localising strings, and Swift does now have built-in support for finding out all the cases an enum has: it’s called CaseIterable and you should definitely use it instead of the hack presented below, which is no longer required. However, the other ideas are still very useful — maybe even more so, in SwiftUI: a PickableEnum is a great tool for automatically creating a SwiftUI Picker, or even alert boxes, for example.
Here’s a very common thing in iOS apps: You have a table view, with a list of items, each one a simple line of text, and a checkmark next to the one that’s selected.
Sure, there are often better ways of doing this – particularly for UI that’s at the core of your app – but still. It’s familiar to users, and for things like settings, it’s very useful. Indeed, the iOS system settings are full of ’em. To take one example to use in this post, in the Messages section, when you tap “Keep Messages”, you can pick “30 Days”, “1 Year” or “Forever”.
Sometimes the choices are dynamic (e.g. picking the default calendar, where the choices depend on which calendars the user has created), but a lot of the time, including the Keep Messages example, there’s a fixed list, and in code, these settings are probably represented by an enum:
enum KeepMessagesOptions: Int
{
case for30Days, for1Year, forever
}
By basing the enum on an Int, each item has an index associated with it (0 for for30Days, 1 for for1Year, 2 for forever), so you can translate from NSIndexPath.row to enum and back again.
But, for storage in the settings, you need something else as well: a permanent identity, separate from the index. Why? Well, let’s say in the next update you decide to add more granularity:
enum KeepMessagesOptions: Int
{
case for30Days, for6Months, for1Year, forever
}
If an iOS 9 user chose to keep their messages forever, and you used the index (2) to store their setting, now that value matches for1Year and they might lose a bunch of messages they wanted to keep. That’s no good.
Of course, you can’t just use the names shown on screen either, because those are a) translated to different languages and b) could have typos that later get fixed or something like that. In general, you never want to be using user-facing strings for settings storage. So you need some specific machine-readable IDs to use instead.
This is all pretty obvious. Why am I belabouring it? Because it’s kind of annoying to have to keep handling this stuff.
In Objective-C, as in C or C++, enums are just a slightly prettier way of defining a constant integer. So, to provide a UI to the user to pick an option, you need to define two lists of strings to represent them (one for machine-readable IDs, one for localised human-readable display names), make a table view controller to present the choices, pass in the appropriate human-readable strings each time you use it, define some kind of API for it to hand the result back, and you have to handle all the translations between index, machine-readable ID and human-readable title whenever you read/write the settings.
It’s not difficult. But it’s tedious.
In Swift however, an enum can do a whole lot more. Let’s define a quick protocol:
protocol PickableEnum
{
var displayName: String { get }
var permanentID: String { get }
static var allValues: [Self] { get }
static func fromPermanentID(id: String) -> Self?
}
And it becomes pretty trivial to make a generic view controller that can pick from any PickableEnum. Add in Futures and you can use it as simply as this:
@IBAction func showChoicesForKeepMessage()
{
// assume we already got the current value from somewhere
EnumPickerController.pickFromEnum(currentValue, from: self).onSuccess
{
newValue in
// do something with newValue, probably involving writing
// newValue.permanentID to NSUserDefaults somewhere.
}
}
That looks pretty nice, but what about the enums themselves – have we just pushed all the extra work into them, when they implement the PickableEnum protocol? Nah – protocol extensions to the rescue:
extension PickableEnum where Self: RawRepresentable, Self.RawValue == Int
{
var displayName: String
{
return Localised("\(self.dynamicType).\(self)")
}
var permanentID: String
{
return String(self)
}
static var allValues: [Self]
{
var result: [Self] = []
var value = 0
while let item = Self(rawValue: value)
{
result.append(item)
value += 1
}
return result
}
static func fromPermanentID(id: String) -> Self?
{
return allValues.indexOf { $0.permanentID == id }.flatMap { self.init(rawValue: $0) }
}
}
Write that once, and now we have sensible default implementations for all of the protocol requirements. So all we have to do is add the conformance to our enum and we’re ready to go:
enum KeepMessagesOptions: Int, PickableEnum
{
case for30Days, for6Months, for1Year, forever
}
Of course, you can use your own implementations; for example, if you needed backward-compatibility with existing settings IDs already in NSUserDefaults:
enum KeepMessagesOptions: Int, PickableEnum
{
case for30Days, for6Months, for1Year, forever
var permanentID: String { return ["30d", "6m", "1y", "forever"][rawValue] }
}
But you don’t have to.
And for human-readable strings, just add them to your app’s localised strings file, using the enum type & cases as keys, which you’d need to do anyway:
"KeepMessagesOptions.for30Days" = "For 30 Days";
// etc
(I’ve assumed, above, that Localised() is a function that uses the standard Cocoa localisation routines to look up the appropriate text in the main bundle. Why not NSLocalizedString()? That’s intended for static strings only, and will probably give you trouble if you run genstrings or something like that on a constructed string like we have here.)
Another possibility is that you could use enums backed by String instead of Int. This has the advantage that you can use the default init(rawValue:) and rawValue members to convert back and forth between ID and case, and the protocol boils down to just allValues and displayName, if you extend it from the standard library RawRepresentable protocol:
protocol PickableEnum: RawRepresentable
{
var displayName: String { get }
static var allValues: [Self] { get }
}
Simpler in theory, but in practice, you still have to index into the list of enum cases to translate to/from table view rows. And you need to define the allValues array by hand for each one. If you find you’re always doing that anyway, using a string-backed enum might be a better bet. Or, in future, Swift might gain a way to access all the possible cases of an enum automagically (at least for enums without associated data) even for those backed by non-integer types. The compiler has that information, after all.1
But in my case, I also stick with integer-backed enums for another reason: because it’s easier to handle them when I need to use the values inside Ferrite’s audio engine (written in Objective-C++, as mentioned previously).
Another minor implementation note: I’ve given the simplest definition above, but in practice I found that it can help if allValues, instead of returning an array of the actual instances, returns an array of tuples, e.g. [(name: String, id: String)]. That’s the data the view controller actually needs, and it reduces the sometimes-“contagious” effect of generics since it only ever needs to deal with known types. That may or may not be valuable to you.
Oh, and here’s another thing… there’s actually nothing saying the type must be an enum. Remember back at the beginning I said that sometimes the list is based on user-supplied data, not a fixed set? You can make your own struct that conforms to PickableEnum and your view controller (or any other widgets you’ve adapted to use it… UIPickerView perhaps?) will accept it just fine, and with a bit of luck, there’s another repetitive chunk of code you never have to write again.
And likes to remind you of that fact when you write switch statements ;) ↫