diff --git a/Sources/SwiftNavigation/CaseBindable.swift b/Sources/SwiftNavigation/CaseBindable.swift index a32a83320..c6208304f 100644 --- a/Sources/SwiftNavigation/CaseBindable.swift +++ b/Sources/SwiftNavigation/CaseBindable.swift @@ -203,6 +203,38 @@ } } + #if Perception + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + @available(visionOS, unavailable) + extension Perception.Bindable { + /// Derives an enumeration of bindings that can be switched over exhaustively _via_ dynamic + /// member lookup. + /// + /// You don't call this subscript directly. Instead, Swift calls it for you when you access a + /// property from the underlying `Value` directly on this bindable object: + /// + /// ```swift + /// @Perception.Bindable var item: Item + /// // ... + /// switch $item.status { + /// case .inStock(let $quantity): + /// Stepper("Quantity: \($quantity.wrappedValue)", value: $quantity) + /// case .outOfStock(let $isOnBackOrder): + /// Toggle("Is on back order?", isOn: $isOnBackOrder) + /// } + /// ``` + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Member.BindingEnumeration where Value: AnyObject { + let binding: SwiftUI.Binding = self[dynamicMember: keyPath] + return binding.cases + } + } + #endif + extension SwiftUI.Binding { public var _$wrappedValue: Value { wrappedValue } } diff --git a/Tests/SwiftNavigationTests/CaseBindableTests.swift b/Tests/SwiftNavigationTests/CaseBindableTests.swift index 0e95081f8..acf4aabab 100644 --- a/Tests/SwiftNavigationTests/CaseBindableTests.swift +++ b/Tests/SwiftNavigationTests/CaseBindableTests.swift @@ -7,6 +7,10 @@ import SwiftUI #endif + #if Perception + import Perception + #endif + final class CaseBindableTests: XCTestCase { func testCasePathableConformance() { let status = Status.inStock(quantity: 100) @@ -103,6 +107,74 @@ #endif } + #if canImport(SwiftUI) && Perception + @available(iOS, introduced: 16, deprecated: 17, obsoleted: 17) + @available(macOS, introduced: 13, deprecated: 14, obsoleted: 14) + @available(tvOS, introduced: 16, deprecated: 17, obsoleted: 17) + @available(watchOS, introduced: 9, deprecated: 10, obsoleted: 10) + final class PerceptionBindableCaseBindableTests: XCTestCase { + override func setUp() async throws { + guard !deploymentTargetIncludesObservation() else { + throw XCTSkip( + """ + PerceptionTests were built against a deployment target too recent for perception checking. + + To force these tests to run on macOS, you can override the target OS version explicitly as: + + swift test -Xswiftc -target -Xswiftc arm64-apple-macosx13.0 + """ + ) + } + } + + @MainActor + func testPerceptionBindableEnumerationDerivesBinding() { + @Perception.Bindable var ref = PerceptionRef(item: Item(name: "name", status: .inStock(quantity: 100))) + switch $ref.status.cases { + case .inStock(let quantity): + XCTAssertEqual(quantity.wrappedValue, 100) + quantity.wrappedValue = 7 + case .outOfStock, .discontinued, .onSale: + XCTFail("Expected 'inStock'") + } + XCTAssertEqual(ref.status, .inStock(quantity: 7)) + } + + @MainActor + func testPerceptionBindableChainsIntoCaseBindableMember() { + @Perception.Bindable var ref = PerceptionRef(item: Item(name: "name", status: .inStock(quantity: 100))) + switch $ref.item.status.cases { + case .inStock(let quantity): + quantity.wrappedValue = 3 + case .outOfStock, .onSale, .discontinued: + XCTFail("Expected 'inStock'") + } + XCTAssertEqual(ref.item.status, .inStock(quantity: 3)) + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func deploymentTargetIncludesObservation() -> Bool { true } + + @_disfavoredOverload + private func deploymentTargetIncludesObservation(_: Void = ()) -> Bool { false } + + @Perceptible + private final class PerceptionRef { + var status: Status { + get { + item.status + } + set { + item.status = newValue + } + } + var item: Item + init(item: Item) { + self.item = item + } + } + #endif + private struct Item { var name: String var status: Status