Swift package
Reusable key-path conversions that preserve SwiftUI binding identity, transactions, and write behavior
A Binding carries more than read and write closures. SwiftUI propagates update context – including animation – through its Transaction. Rebuilding a binding with Binding(get:set:) can sever that path: the value still changes, but the update may lose the animation information attached to the original binding.
KeyPathMapping expresses the same transformation as a reusable, hashable mapping that remains part of the binding’s structural path.
import SwiftUI
struct ProgressEditor: View {
@SwiftUI.State
private var progress: Float = 0.25
var body: some View {
let sliderValue = Binding<Double>(
get: { Double(progress) },
set: { newValue in
progress = Float(newValue)
}
)
Slider(value: sliderValue, in: 0...1)
}
}
import KeyPathMapping
import SwiftUI
struct ProgressEditor: View {
@SwiftUI.State
private var progress: Float = 0.25
var body: some View {
Slider(
value: $progress[
convert: .to(Double.self)
],
in: 0...1
)
}
}
Binding supports dynamic-member lookup through writable key paths. A computed property can therefore derive a binding without reconstructing it from unrelated closures:
extension BinaryFloatingPoint {
var double: Double {
get { Double(self) }
set { self = Self(newValue) }
}
}
Slider(value: $progress.double, in: 0...1)
This preserves the structural path SwiftUI understands. The trade-off is where the transformation lives: a private extension cannot be reused, while a public double property becomes part of every conforming type’s namespace.
KeyPathMapper gives transformations a dedicated, generic namespace. The predefined numeric conversion used above follows this shape:
extension KeyPathMapper.MutatingConversionTo
where Root: BinaryFloatingPoint {
static func to<T: BinaryFloatingPoint>(
_ type: T.Type
) -> Self where Member == T {
.inline(
extract: { T($0) },
embed: { Root($0) }
)
}
}
Apply it through [convert:], and Binding’s dynamic-member subscript continues the original key-path chain. The conversion stays reusable without adding application-specific properties to Float, Double, or other numeric types.
Mappings use labeled subscripts: [map:] for read-only transforms, [convert:] for round-trippable conversions, [getter:] and [setter:] for one side of the behavior, and [getter:setter:] for a custom pair.
Setter mappings attach write hooks while keeping the original binding path intact.
Slider(
value: $value[
setter: .onDidSet { newValue in
if newValue > 0.8 {
haptics.impact()
}
}
]
)
Enable the PredefinedConversions package trait on Swift 6.1+ to add common mappings such as .optional, .safeIndex(index), .unwrapped(with:), and numeric .to(...). Define a custom conversion only when extraction and embedding form a meaningful reusable relationship, and test its round trip when the transform can lose information.
| Mapping | Capability |
|---|---|
[map:] | Read-only derivation |
[convert:] | Round-trippable extraction and embedding |
[getter:] / [setter:] | Customize one direction only |
[getter:setter:] | Define a complete custom pair |
[mapPath:] | Select a reusable mapping by key path |
Choose ReadonlyConversionTo, MutatingConversionTo, or NonMutatingConversionTo according to how write-back works. Stable mapping identity can be supplied explicitly when two values of the same mapping type must compare differently.