Swift package
Compose optional key paths and map SwiftUI bindings without losing animation transactions
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 an update may arrive without the animation information attached to the original binding.
KeyPathsExtensions re-exports KeyPathMapping, so the same focused import provides reusable, key-path-based binding transformations.
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 KeyPathsExtensions
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 the conversion through [convert:], and Binding's dynamic-member subscript continues the original key-path chain. The mapping stays reusable without adding application-specific properties to Float, Double, or other numeric types.
Use [map:] for read-only derivation, [convert:] for extraction and write-back, or [getter:setter:] when a transformation needs a custom pair. swift-keypaths-extensions enables the predefined conversions, including numeric .to(...), through its package dependency.
Swift can express \Root.child?.count directly, but separately supplied paths stop composing once one side is optional. The package restores that operation while preserving the strongest key-path type available.
let child: KeyPath<Root, Root.Child?> = \.child
let count: KeyPath<Root.Child, Int> = \.count
let optionalCount: KeyPath<Root, Int?> =
child.appending(path: count)
let countWithDefault: KeyPath<Root, Int> =
optionalCount.unwrapped(with: 0)
Use withOptionalRoot() to lift a standalone path into an optional context. Keep unwrapping nonaggressive by default: writes through a nil root are ignored unless replacing nil is explicitly supported by the value semantics.
| API | Result |
|---|---|
[convert:] | Apply a reusable read/write transformation through a key path |
appending(path:) | Compose independently supplied paths through an optional root |
withOptionalRoot() | Lift a path so its root may be absent |
unwrapped(with:) | Expose an optional value through an explicit default |
unsafeSendable() | Spell an audited sendable key-path existential |
Writable overloads preserve writability only when both component paths support it. unsafeSendable() is an unchecked cast; it does not make captured state thread-safe.