I’m finally jumping on the SwiftUI train, albeit tentatively, but before I get started actually working with the framework there’s a lot I need to catch up on - some basic prerequisites if you will. In this series (if I actually get around to finishing it) I want to cover four key pieces of Swift that play a huge role in making SwiftUI feel like magic
Let’s start with key paths.
While everything else in the aforementioned list was introduced with SwiftUI, key paths are not new. They were introduced in Swift 4, which is several Swift lifetimes ago. Technically though key paths originated in Objective-C as a means of accessing an object’s properties as part of the key-value coding protocol. If an object was key-value coding compliant, you could use a String value to indirectly access a property.
id someValue = [myObject valueForKeyPath:@"foo.bar.baz"];
Since Swift was built on top of and used to interact with exclusively Objective-C API in the early days an implementation of key paths was necessary if you wanted to leverage the power of KVO. But it was messy. If you wanted to observe values on a Swift type, it had to inherit from NSObject
and you had to mark these properties as dynamic
.
class Person: NSObject {
dynamic var name: String = "Jane Appleseed"
dynamic var age: Int = 50
}
Having to inherit from an Objective-C class meant that you were immediately limited. You could not observe values on any value types such as structs and enums nor could you use generic classes. Using an NSObject
instance you could now observe values, albeit with API that was not very Swift like.
person.addObserver(self, forKeyPath: "name", options: NSKeyValueObservingOptions.New, context: &kvoContext)
What stands out immediately is that the key path argument takes a String literal. Since any value could be passed in, this violated the type safety contract that Swift sought enforce. Literally everyone complained about this. Not only did it not feel like bad Swift, but all the features of Objective-C KVO weren’t even supported.
Swift 3 sought to improve on this this by introducing the #keyPath
syntax. A key path expression was created by passing in a sequence of object properties.
var user = Person(name: "Jane Appleseed", age: 42)
let personNameKeyPath = #keyPath(Person.name)
This improved the API in some ways - creating a key path expression meant that the compiler could verify that the property existed, but several disadvantages still remained. Key path expressions still relied on the Objective-C runtime which meant the object being observed needed to be an Objective-C class, or individual properties had to be exposed to the runtime.
Despite the compile time check the key path was also ultimately resolved to a string, which meant a loss of type information. Key path APIs always returned a value of type Any
let username = user.value(forKeyPath: personNameKeyPath) // Any
Similarly because type information was not preserved you could use the key path API to inadvertently assign values of the incorrect type to properties.
user.setValue(10, forKeyPath: personNameKeyPath) // No compile time error
In addition, key path expressions could only be used on objects and not on collections or other subscriptable types. Lastly, it was not possible to refer to object properties without invoking them.
None of these might have seemed like a huge deal at the time, but with Combine and SwiftUI coming down the pipeline key paths had to be improved significantly.
Smart key paths were introduced in SE-0161 as a language feature for Swift 4 and needed to account for all the deficiencies of #keyPath expressions.
Any
based APILet’s work with the following example
struct Person {
let name: String
var age: Int
let address: Address
let friends: [Person]
let pet: Pet?
}
struct Address {
let street: String
}
struct Pet {
let name: String
}
To create a key path you start with a backslash, followed by the base type and the property being observed using the standard dot syntax when referring to properties.
let nameKeyPath = \Person.name
If the base type can be inferred it can be omitted.
\.name
The backslash is a sigil or character that helps the compiler disambiguate between an execution of the property or a specification of a type property. If you go through the Swift Evolution proposal it says that other syntactical approaches were considered, but ended up being more confusing.
// Examples of other possible syntacical approaches
#Person.name // # already has established meaning with #if and #available
Person.name // Similar to referring to function type, but confusing if there is a type property of the same name
`Person.name
Like Objective-C key paths, Swift key paths can be composed in sequence
let streetAddressKeyPath = \Person.address.street
Because they are statically type checked, we can use optional chaining as well, which creates a key path of the right type (more on this in a bit).
let petNameKeyPath = \Person.pet?.name
Finally, key paths can also be used on collection or subscriptable types to access inner values.
let friendNameKeyPath = \Person.friends[0].name
Once you have a key path, you can use subscript notation to access the actual value on an instance.
let address = Address(street: "123 Street")
var person = Person(name: "Jane Appleseed", age: 42, address: address, friends: [], pet: nil)
let name = person[keyPath: nameKeyPath] // "Jane Appleseed"
You’ll notice that I’m using a keyPath
label inside the subscript; this is to make it distinct from regular subscripting. Key path subscript syntax can also be used to set values.
let ageKeyPath = \Person.age
person[keyPath: ageKeyPath] = 43
So far all the types I’ve defined have been value types and key paths have worked with no issues. They work just as well with reference types.
enum Make {
case toyota
}
class Vehicle {
let year: Int
let make: Make
init(year: Int, make: Make) {
self.year = year
self.make = make
}
}
let car = Vehicle(year: 2009, make: .toyota)
let make = car[keyPath: \.make]
So how do key paths work? Unlike #keyPath
expressions, “smart” key paths are represented by the KeyPath
type which are a hierarchy of progressively more specific generic classes.
AnyKeyPath
At the top of the hierarchy is AnyKeyPath
, a fully type erased key path that can refer to any route through an object graph. If we were to keep a reference to multiple key paths across different objects, the type of the resulting key path is an array of AnyKeyPath
let anyKeyPaths = [\Person.address, \Pet.name]
PartialKeyPath
Next up in specificity is a partial key path, a key path that is only partially type erased. Where AnyKeyPath
defines a key path that contains any base type and any value, a partial key path has a fixed base. This is evident from the generic class definition: class PartialKeyPath<Root>
.
Partial key paths indicate that we know what the base type is but the values can be any property within the base.
let personKeyPaths = [\Person.age, \Person.address.street]
Here we have two key paths that originate from the Person
base type. The values that each key path defines is different - one is an Int
value, the other String
, but both are routed to from the Person
base type.
KeyPath
Moving further down the hierarchy we have the KeyPath
class, that defines a key path with a specific base type to a specific resulting value type: KeyPath<Root, Value>
.
All of the key paths I defined earlier resulted in KeyPath
instances.
let nameKeyPath = \Person.name
If you option click to inspect the generated type you can see that it is KeyPath<Person, String>
. Because the compiler knows the type of the base and the resulting value it can do that sweet compile time checking we know and love.
let name = person[keyPath: nameKeyPath]
The type of name
is always going to be String
since the compiler knows what the resulting value is at the end of the key path. This avoids the awkwardness of Any
values and having to use optional casting all over the place.
WritableKeyPath
Once the types of the base and value are known, the compiler can then guarantee whether the property is read only or read-write. Key paths of type KeyPath
are read only. If you try to set the value of person.name
in the previous example the compiler will yell at you.
person[keyPath: nameKeyPath] = "Jane A." // Cannot assign through subscript: 'nameKeyPath' is a read-only key path
Let’s go back to to the type declaration and make name
a var
property. If you inspect the associated key path you’ll notice that it is now of type WritableKeyPath<Person, String>
.
Mutable value type bases or chained mutable value type bases will always result in a writable key path. The compiler can now guarantee that the type can be written into, and you don’t to worry about having to annotate using mutating
.
ReferenceWritableKeyPath
The counter part for classes is ReferenceWritableKeyPath<Root, Value>
. A reference writable key path supports reading from and writing to the resulting value using reference semantics. With writable key paths, the compiler ensures that both the instance and the underlying property are mutable before allowing a write, but with reference writable key paths values can be written by invoking a property setter.
class WrapperView {
var innerView: UIView
init(view: UIView) {
self.innerView = view
}
}
let keyPath = \WrapperView.innerView
In this example the type of keyPath
is ReferenceWritableKeyPath<WrapperView, UIView>
. A reference writable key path can be created even if the base type is a value type, as long as the value being written into is a reference type.
Key paths can dynamically form new key paths from other key paths by using the append method.
let nameKeyPath = \Person.name
let nameCountKeyPath = nameKeyPath.appending(path: \.count)
In this example I’m using the key path for Person.name
to derive the key path for the count property on the resulting String value.
Now that we know what key paths are and how they are created, what do we actually use them for?
If you think about a basic function of key paths - being able to refer to a property on a type without an instance of it, you can improve existing API and come up with new in many useful ways.
Map & Filter
The existing map function can be rewritten to take key path arguments instead values of a given type.
extension Sequence {
func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
return map { $0[keyPath: keyPath] }
}
}
Given a type and an array of instances
struct Post {
let id: String
let pubDate: Date
let title: String
let isDraft: Bool
}
let posts = [
Post(id: "1", pubDate: Date(), title: "SwiftUI Part 1", isDraft: false),
Post(id: "2", pubDate: Date().addingTimeInterval(100), title: "SwiftUI Part 2", isDraft: false),
Post(id: "2", pubDate: Date().addingTimeInterval(200), title: "SwiftUI Part 3", isDraft: true),
]
You can use the key path variant of map to easily obtain all the post ids
let ids = posts.map(\.id)
This variant is so useful that as of Swift 5.2 it is now part of the language, introduced in SE-0249. As part of the implementation \Root.value
can be used anywhere functions of (Root) -> Value
are allowed, the filter function for instance
let drafts = posts.filter(\.isDraft)
The only limitation here is only key path literals are allowed for now. So this works
posts.filter(\.isDraft)
but not this
let isDraftKeyPath = \Post.isDraft
posts.filter(isDraftKeyPath)
The sorted()
function is another function that would benefit from the concise key path based API. It is not part of the language yet, but you can find a version, along with other great examples in John Sundell’s post on key paths.
Combine
The Combine framework uses key paths in several places to keep the API concise and expressive. Here’s a simple example
var subscriptions = Set<AnyCancellable>()
let url = URL(string: "https://someblog.com/posts")!
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [Post].self, decoder: JSONDecoder())
.sink(receiveCompletion: { print($0) }, receiveValue: { posts in print(posts.count) })
.store(in: &subscriptions)
If you’ve written asynchronous networking code you know how much of a pain this is using Foundation API. When you create a data task for a url, the completion handler has three arguments defined - data, response and an error. You have to inspect each value and do the usual dance.
With Combine, we can just map on the data using a key path and write far more readable code.
Another example is the assign(to:on:)
operator.
class ViewModel {
var date: Date
init(date: Date) {
self.date = date
}
}
let viewModel = ViewModel(date: Date())
let cancellable = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.assign(to: \ViewModel.date, on: viewModel)
The assign to operator is used to update the date
property on the ViewModel
instance every time a new value is received. The key path API allows us to write code in a much more declarative manner, stating what we want to happen, rather than having to invoke property setters.
SwiftUI
Let’s bring it back to SwiftUI, the point of this entire post. You’re going to run into key paths all over the place, but given that they’re not as tentpole a feature as function builders or property observers most posts just assume you know what’s going on.
You’ll run into when setting and retrieving values from the environment object
let contentView = ContentView().environment(\.managedObjectContext, context)
struct ContentView: View {
@Environment(\.managedObjectContext) var viewContext
// implementation...
}
They are also used when creating ForEach
views, which require a key path specified as an identifier.
ForEach(reminders, id: \.self) { reminder in
// ...
}
Key paths ultimately play a role in allowing for a more declarative, expressive and concise API, part of what makes SwiftUI fun. That about covers it. In the next post in this series, we’ll look at property wrappers.