SwiftUI Bindings with CoreData
If you’ve been playing with SwiftUI for a while, you’re likely familiar with the liberal use of @State
and @Binding
throughout the library. For instance, consider the following simple to-do item editor:
enum Priority: Int, CaseIterable {
case low, normal, high, urgent
var title: LocalizedStringKey {
switch self {
case .low: return "Low"
case .normal: return "Normal"
case .high: return "High"
case .urgent: return "Urgent"
}
}
}
struct Item: Identifiable {
var id: UUID
var title: String
var priority: Priority = .normal
var dueDate: Date = .distantPast
var notes: String = ""
}
struct Editor: View {
@State var item: Item
var body: some View {
Form {
TextField("Title", text: $item.title)
Picker("Priority", selection: $item.priority) {
ForEach(Priority.allCases, id: \.self) { priority in
Text(priority.title).tag(priority)
}
}
DatePicker("Due Date", selection: $item.dueDate,
displayedComponents: .date)
TextField("Notes", text: $item.notes)
}
}
}
There’s a single @State
property containing a struct
type, and this happily hands out the bindings required by TextField
, Picker
, and DatePicker
. However, if the properties in the Item
type were optional, the compiler wouldn’t be so happy:
@State var title: String?
...
// Error: "Cannot convert value of type 'Binding<String?>' to expected
// argument type 'Binding<String>'"
TextField("Title", text: $title)
This is actually what you’ll find if you try to use a CoreData managed object type, because all its object-type properties are nullable:
class Item: NSManagedObject {
@NSManaged var title: String?
@NSManaged var priority: Int
@NSManaged var dueDate: Date?
@NSManaged var notes: String?
var priorityEnum: Priority {
get { Priority(rawValue: priority) ?? .normal }
set { priority = newValue.rawValue }
}
}
struct Editor: View {
@ObservedObject var item: Item
var body: some View {
Form {
// Errors everywhere!
TextField("Title", text: $item.title)
Picker("Priority", selection: $item.priority) {
ForEach(Priority.allCases, id: \.self) { priority in
Text(priority.title).tag(priority)
}
}
DatePicker("Due Date", selection: $item.dueDate,
displayedComponents: .date)
TextField("Notes", text: $item.notes)
}
}
}
Unwrapping optional bindings
What are we to do with this? Well, there’s an initializer on Binding
that looks like it’ll help—it takes a Binding<Optional<Value>>
and returns a Binding<Value>
.
/// Creates an instance by projecting the base optional value to its
/// unwrapped value, or returns `nil` if the base value is `nil`.
public init?(_ base: Binding<Value?>)
Unfortunately, as the comment says, it only works if the value isn’t currently nil
. So, you need to assign a default there first in order to use this. It’ll work, certainly, but quickly becomes awkward, not least because you probably want to use your binding within an @ViewBuilder
block, and you can’t put general code in those—the compiler complains, because it needs every statement to evaluate with a return type expected by the @ViewBuilder
in use.
@State var title: String? = nil
var body: some View {
Form {
if title == nil {
// Error: "'()' is not convertible to 'String?'"
title = ""
}
TextField("Title", text: Binding($title)!)
}
}
Now, this works if you lift the setter up out of the Form
block, but that becomes unwieldy with more than a couple of values:
@State var title: String? = nil
@State var notes: String? = nil
// etc...
var body: some View {
if title == nil {
title = ""
}
if notes == nil {
notes = ""
}
// etc...
Form {
TextField("Title", text: Binding($title)!)
TextField("Notes", text: Binding($notes)!)
// etc...
}
}
You’re looking at a minimum four lines of code for each additional optional property. There’s a better way, though: you can add a new initializer to Binding
that’s similar to the existing unwrapping one, but which takes a default value to assign first so it will guarantee a non-nil result:
extension Binding {
init(_ source: Binding<Value?>, _ defaultValue: Value) {
// Ensure a non-nil value in `source`.
if source.wrappedValue == nil {
source.wrappedValue = defaultValue
}
// Unsafe unwrap because *we* know it's non-nil now.
self.init(source)!
}
}
This initializer thus inlines the if value == nil
rule above, leading to cleaner code and a one-line initialization:
@State var title: String? = nil
@State var notes: String? = nil
var body: some View {
Form {
TextField("Title", text: Binding($title, ""))
TextField("Notes", text: Binding($notes, ""))
}
}
Assigning nil or non-nil to a non-optional binding
A further situation occurs, though. What if nil is actually a reasonable value? Perhaps you would rather have a nil value than an empty string. Which of these looks better?
if !item.title.isEmpty {
// use item.title
}
// or
if let title = item.title {
// use title
}
The answer is entirely subjective, of course, but if let
is generally considered a “swifty” way to do things.
So, now you have a binding which will ensure nil is translated into an ‘empty’ value of some kind, but you’d really like to take any empty value and represent it as nil while still holding a Binding<Value>
rather than Binding<Value?>
. This, it turns out, is also possible thanks to the regular initializer for Binding
, which uses closures to get and set the underlying value:
/// Initializes from functions to read and write the value.
public init(get: @escaping () -> Value, set: @escaping (Value) -> Void)
As long as the underlying Value
is Equatable
, it’s quite easy to use those to implement the replace-nil functionality we need:
init(_ source: Binding<Value?>, replacingNilWith nilValue: Value) {
self.init(
get: { source.wrappedValue ?? nilValue },
set: { newValue in
if newValue == nilValue {
source.wrappedValue = nil
}
else {
source.wrappedValue = newValue
}
})
}
Here the source
binding is held by the two closures. The getter just uses the ??
operator to return the specified ‘nil value’ if necessary, and the setter explicitly checks if it’s been given that nil value, and transparently assigns nil to the source
binding if so. This works well for CoreData types (avoid allocating byte storage if a ‘nil’ marker will do), and also for truly optional items. An example would be the ‘notes’ in our to-do item. A title is expected, but notes may be present on quite a small number of items, so this is what we’d use in that case:
Form {
// Items should have titles, so use a 'default value' binding
TextField("Title", text: Binding($item.title, "New Item"))
// Notes are entirely optional, so use a 'replace nil' binding
TextField("Notes", text: Binding($item.notes, replacingNilWith: ""))
}
Now your title will start out automatically with a value of "New Item"
, and if everything is deleted from the notes field that property will be set to nil.
Binding nil/non-nil status directly
The ‘due date’ in our to-do item is another item which is legitimately nil when not used—not all to-do items have an explicit deadline—but there’s no reasonable ‘empty’ value for a date. We might use the epoch, or Date.distantPast
, but the user has no easy way of inputting exactly that value to say “don’t use a date.” Instead you’d likely give your user a Toggle
that turns the date on or off: when they turn it on, a default value is applied and a Date Picker
created. Turning it off would take away the picker and set the value to nil.
This is also quite easy to assemble:
init<T>(isNotNil source: Binding<T?>, defaultValue: T) where Value == Bool {
self.init(get: { source.wrappedValue != nil },
set: { source.wrappedValue = $0 ? defaultValue : nil })
}
This creates a Binding<Bool>
from a Binding<T?>
and a default value for T
. The getter returns whether the Binding<T?>
holds nil or not, and the setter assigns either nil or the supplied default value. This is quite simple to use, too:
Toggle("Has Due Date",
isOn: Binding(isNotNil: $item.dueDate, defaultValue: Date()))
if item.dueDate != nil {
DatePicker(DatePicker("Due Date", selection: Binding($item.dueDate)!,
displayedComponents: .date))
}
Here you’ve used the Binding(isNotNil:defaultValue:)
for the Toggle
, and a regular unwrapping binding for the DatePicker
.
All together now…
This leads to a CoreData-backed form binding to optional properties that looks almost as concise as the pure struct
format:
enum Priority: Int, CaseIterable {
case low, normal, high, urgent
var title: LocalizedStringKey {
switch self {
case .low: return "Low"
case .normal: return "Normal"
case .high: return "High"
case .urgent: return "Urgent"
}
}
}
class Item: NSManagedObject {
@NSManaged var title: String?
@NSManaged var priority: Int
@NSManaged var dueDate: Date?
@NSManaged var notes: String?
var priorityEnum: Priority {
get { Priority(rawValue: priority) ?? .normal }
set { priority = newValue.rawValue }
}
}
struct Editor: View {
@ObservedObject var item: Item
var body: some View {
Form {
TextField("Title", text: Binding($item.title, "New Item"))
Picker("Priority", selection: Binding($item.priority, .normal)) {
ForEach(Priority.allCases, id: \.self) { priority in
Text(priority.title).tag(priority)
}
}
Toggle("Has Due Date", isOn: Binding(isNotNil: $item.dueDate,
defaultValue: Date()))
if item.date != nil {
DatePicker("Due Date", selection: Binding($item.dueDate!),
displayedComponents: .date)
}
TextField("Notes", text: Binding($item.notes, replacingNilWith: ""))
}
}
}
Available Now!
These extensions to Binding
are available now via the Swift Package Manager. It uses Swift v5.1 and tags its contents with the appropriate availability modifiers for macOS, iOS, tvOS, and watchOS. Including it is simple:
let package = Package(
...
dependencies: [
...
.package(url: "https://github.com/AlanQuatermain/AQUI.git", from: "0.1.0")
],
swiftLanguageVersions: [
...
.version("5.1")
]
)
Using it in code is straightforward too:
import SwiftUI
import AQUI
...
TextField("Notes", text: Binding($cdObject.notes, replacingNilWith: ""))
...