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.