binding-with-casepath
Advanced Techniques with Binding
: Transforming and Adapting State
While the standard property wrappers handle most state management needs, you will often encounter situations where the shape of your state doesn’t perfectly match the requirements of a SwiftUI view. For example, a view might need a Binding<String>
, but your model provides a Binding<String?>
. Or a view needs to bind to the associated value of an enum
case.
SwiftUI’s Binding
type is incredibly powerful, and with a few extension methods, we can transform bindings to fit our exact needs. Let’s use the following data model for our examples:
1
2
3
4
5
6
7
8
9
10
struct Item: Hashable, Identifiable {
let id = UUID()
var color: Color?
var status: Status
enum Status: Hashable {
case inStock(quantity: Int)
case outOfStock(isOnBackOrder: Bool)
}
}
Given a state variable like @State var item: Item
, a binding to its status, $item.status
, would have the type Binding<Item.Status>
. Let’s see how we can manipulate this and other bindings.
Drilling Down into Bindings with Key Paths
SwiftUI has built-in support for creating bindings to the properties of a bound value. When you write $item.status
, Swift is transparently applying a transform to map the Binding<Item>
to a Binding<Item.Status>
. This is conceptually achieved through a map
function that uses a WritableKeyPath
:
1
2
3
4
5
extension Binding {
func map<LocalValue>(_ keyPath: WritableKeyPath<Value, LocalValue>) -> Binding<LocalValue> {
self[dynamicMember: keyPath]
}
}
You rarely need to call this map
function directly, as the .
syntax ($item.status
) is convenient shorthand for the same operation. It’s the simplest way to transform a Binding<A>
into a Binding<B>
.
Handling Optionals: The unwrap
Extension
A very common scenario is dealing with optional state. For instance, our item.color
property is a Color?
, making $item.color
a Binding<Color?>
. However, SwiftUI’s ColorPicker
view requires a Binding<Color>
. It cannot work with optionals.
To bridge this gap, we can create a handy unwrap
extension that transforms an optional binding Binding<A?>
into a non-optional binding Binding<A>?
. The result is itself an optional: it will be nil
if the original state is nil
, or a valid, non-optional Binding
if the state has a value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension Binding {
/// Transforms a `Binding<Wrapped?>` into a `Binding<Wrapped>?`.
/// If the original binding's value is `nil`, this returns `nil`.
func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
// If the wrapped value is nil, we can't create a binding.
guard let value = self.wrappedValue else { return nil }
// Create a new binding that gets the non-optional value
// and sets the optional value on the original source.
return Binding<Wrapped>(
get: { value },
set: { self.wrappedValue = $0 }
)
}
}
Usage:
You can use this with an if let
statement to conditionally show a view that requires a non-optional binding.
1
2
3
4
5
6
7
8
9
10
11
12
struct ItemEditorView: View {
@Binding var item: Item
var body: some View {
// Only show the ColorPicker if a binding to a non-optional Color can be created.
if let colorBinding = $item.color.unwrap() {
ColorPicker("Item Color", selection: colorBinding)
} else {
Text("This item has no color set.")
}
}
}
A More General Solution for Enums: matching
with CasePaths
The unwrap
function is actually a specific version of a broader problem: how do we bind to the associated value of an enum
case? An Optional
is just an enum with two cases: .none
and .some(Wrapped)
. unwrap
effectively extracts the associated value from the .some
case.
To create a more generic solution for any enum, we can leverage the excellent CasePaths library. A CasePath
is like a “key path for an enum case,” allowing you to reliably extract associated values from and embed them back into an enum.
Building on this, we can create a matching
function that returns a binding to an associated value if and only if the binding’s value is currently in that specific case.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Requires the 'CasePaths' library: https://github.com/pointfreeco/swift-case-paths
import CasePaths
extension Binding {
/// Returns a new binding focused on a specific case of an enum.
/// If the original binding's value does not match the case, this returns `nil`.
func matching<Case>(
_ casePath: CasePath<Value, Case>
) -> Binding<Case>? {
// Attempt to extract the associated value from the current state.
guard let `case` = casePath.extract(from: self.wrappedValue) else { return nil }
// Create a new binding.
return Binding<Case>(
// The getter returns the extracted associated value.
get: { `case` },
// The setter embeds the new value back into the case and updates the original source.
set: { `case` in self.wrappedValue = casePath.embed(`case`) }
)
}
}
Usage:
This powerful extension allows you to build UI that adapts to the state of an enum. For our Item.Status
, we can show completely different controls for each case.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ItemStatusView: View {
@Binding var status: Item.Status
var body: some View {
VStack {
// This UI will only appear and work if status is .inStock
if let quantityBinding = $status.matching(/Item.Status.inStock) {
Stepper("Quantity: \(quantityBinding.wrappedValue)", value: quantityBinding)
}
// This UI will only appear and work if status is .outOfStock
if let onBackOrderBinding = $status.matching(/Item.Status.outOfStock) {
Toggle("Is on backorder", isOn: onBackOrderBinding)
}
}
}
}
These techniques for transforming Binding
are essential for writing clean, decoupled SwiftUI code. They allow your views to remain simple and focused on their specific data requirements, while your data models can remain complex and robust.