diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 52c3ebb1f..955923df9 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -16,29 +16,26 @@ import MarketUI import MarketWorkflowUI +import Perception // for WithPerceptionTracking import ViewEnvironment import WorkflowSwiftUIExperimental +import WorkflowUI + +// Compiler requires explicit conformance to `Screen`, or else: "Conditional +// conformance of type 'ViewModel' to protocol 'SwiftUIScreen' +// does not imply conformance to inherited protocol 'Screen'" +extension MainWorkflow.Rendering: SwiftUIScreen, Screen { + var model: Model { + self + } -struct MainScreen: SwiftUIScreen { - let title: String - let didChangeTitle: (String) -> Void - - let allCapsToggleIsOn: Bool - let allCapsToggleIsEnabled: Bool - let didChangeAllCapsToggle: (Bool) -> Void - - let didTapPushScreen: () -> Void - let didTapPresentScreen: () -> Void - - let didTapClose: (() -> Void)? - - static func makeView(model: ObservableValue) -> some View { - MainScreenView(model: model) + public static func makeView(store: Store) -> some View { + MainView(store: store) } } -private struct MainScreenView: View { - @ObservedObject var model: ObservableValue +private struct MainView: View { + @BindableStore var store: Store @Environment(\.viewEnvironment.marketStylesheet) private var styles: MarketStylesheet @Environment(\.viewEnvironment.marketContext) private var context: MarketContext @@ -50,16 +47,13 @@ private struct MainScreenView: View { @FocusState var focusedField: Field? var body: some View { - ScrollView { VStack { + WithPerceptionTracking { ScrollView { VStack { Text("Title") .font(Font(styles.headers.inlineSection20.heading.text.font)) TextField( "Text", - text: model.binding( - get: \.title, - set: \.didChangeTitle - ) + text: $store.title ) .focused($focusedField, equals: .title) .onAppear { focusedField = .title } @@ -67,11 +61,8 @@ private struct MainScreenView: View { ToggleRow( style: context.stylesheets.testbed.toggleRow, label: "All Caps", - isEnabled: model.allCapsToggleIsEnabled, - isOn: model.binding( - get: \.allCapsToggleIsOn, - set: \.didChangeAllCapsToggle - ) + isEnabled: store.allCapsToggleIsEnabled, + isOn: $store.allCapsToggleIsOn ) Spacer(minLength: styles.spacings.spacing50) @@ -81,12 +72,12 @@ private struct MainScreenView: View { Button( "Push Screen", - action: model.didTapPushScreen + action: store.action(.pushScreen) ) Button( "Present Screen", - action: model.didTapPresentScreen + action: store.action(.presentScreen) ) Button( @@ -94,15 +85,15 @@ private struct MainScreenView: View { action: { focusedField = nil } ) - } } + } } } } } -extension MainScreen: MarketBackStackContentScreen { +extension MainWorkflow.Rendering: MarketBackStackContentScreen { func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { MarketNavigationItem( - title: .text(.init(regular: title)), - backButton: didTapClose.map { .close(onTap: $0) } ?? .automatic() + title: .text(.init(regular: state.title)), + backButton: .close(onTap: { fatalError("TODO") }) // didTapClose.map { .close(onTap: $0) } ?? .automatic() ) } @@ -115,15 +106,9 @@ import SwiftUI struct MainScreen_Preview: PreviewProvider { static var previews: some View { - MainScreen( - title: "New item", - didChangeTitle: { _ in }, - allCapsToggleIsOn: true, - allCapsToggleIsEnabled: true, - didChangeAllCapsToggle: { _ in }, - didTapPushScreen: {}, - didTapPresentScreen: {}, - didTapClose: {} + MainWorkflow.Rendering( + state: .init(title: "Test"), + sendAction: { _ in } ) .asMarketBackStack() .marketPreview() diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index d8b0e5ae0..308e2ae39 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import ComposableArchitecture // for ObservableState import MarketWorkflowUI import Workflow @@ -25,6 +26,7 @@ struct MainWorkflow: Workflow { case presentScreen } + @ObservableState struct State { var title: String var isAllCaps: Bool @@ -64,24 +66,27 @@ struct MainWorkflow: Workflow { } } - typealias Rendering = MainScreen + typealias Rendering = ViewModel func render(state: State, context: RenderContext) -> Rendering { - let sink = context.makeSink(of: Action.self) - - return MainScreen( - title: state.title, - didChangeTitle: { sink.send(.changeTitle($0)) }, - allCapsToggleIsOn: state.isAllCaps, - allCapsToggleIsEnabled: !state.title.isEmpty, - didChangeAllCapsToggle: { sink.send(.changeAllCaps($0)) }, - didTapPushScreen: { sink.send(.pushScreen) }, - didTapPresentScreen: { sink.send(.presentScreen) }, - didTapClose: didClose + ViewModel( + state: state, + sendAction: context.makeSink(of: Action.self).send ) } } +extension MainWorkflow.State { + var allCapsToggleIsOn: Bool { + get { isAllCaps } + set { fatalError("TODO") } + } + + var allCapsToggleIsEnabled: Bool { + !title.isEmpty + } +} + private extension String { var isAllCaps: Bool { allSatisfy { character in diff --git a/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift new file mode 100644 index 000000000..00daa25ab --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/BindableStore.swift @@ -0,0 +1,38 @@ +// Copied from https://github.com/pointfreeco/swift-composable-architecture/blob/acfbab4290adda4e47026d059db36361958d495c/Sources/ComposableArchitecture/Observation/BindableStore.swift + +import ComposableArchitecture +import SwiftUI + +/// A property wrapper type that supports creating bindings to the mutable properties of a +/// ``Store``. +/// +/// Use this property wrapper in iOS 16, macOS 13, tvOS 16, watchOS 9, and earlier, when `@Bindable` +/// is unavailable, to derive bindings to properties of your features. +/// +/// If you are targeting iOS 17, macOS 14, tvOS 17, watchOS 9, or later, then you can replace +/// ``BindableStore`` with SwiftUI's `@Bindable`. +@available(iOS, deprecated: 17, renamed: "Bindable") +@available(macOS, deprecated: 14, renamed: "Bindable") +@available(tvOS, deprecated: 17, renamed: "Bindable") +@available(watchOS, deprecated: 10, renamed: "Bindable") +@propertyWrapper +@dynamicMemberLookup +struct BindableStore { + var wrappedValue: Store + init(wrappedValue: Store) { + self.wrappedValue = wrappedValue + } + + var projectedValue: BindableStore { + self + } + + subscript( + dynamicMember keyPath: ReferenceWritableKeyPath, Subject> + ) -> Binding { + Binding( + get: { self.wrappedValue[keyPath: keyPath] }, + set: { self.wrappedValue[keyPath: keyPath] = $0 } + ) + } +} diff --git a/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift new file mode 100644 index 000000000..6280a8564 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/Binding+Observation.swift @@ -0,0 +1,47 @@ +// Copied from https://github.com/pointfreeco/swift-composable-architecture/blob/acfbab4290adda4e47026d059db36361958d495c/Sources/ComposableArchitecture/Observation/Binding%2BObservation.swift + +import ComposableArchitecture +import SwiftUI + +// NB: These overloads ensure runtime warnings aren't emitted for errant SwiftUI bindings. +#if DEBUG +extension Binding { + subscript< + State: ObservableState, + Action, + Member: Equatable + >( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Bindable { + subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} + +extension BindableStore { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { _ in fatalError("TODO") } + ) + } +} +#endif diff --git a/Samples/SwiftUITestbed/Sources/Observation/Store.swift b/Samples/SwiftUITestbed/Sources/Observation/Store.swift new file mode 100644 index 000000000..d8e513921 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/Store.swift @@ -0,0 +1,68 @@ +import ComposableArchitecture // for ObservableState and Perception + +@dynamicMemberLookup +final class Store: Perceptible { + typealias Model = ViewModel + + private var model: Model + private let _$observationRegistrar = PerceptionRegistrar() + + var state: State { + _$observationRegistrar.access(self, keyPath: \.state) + return model.state + } + + func send(_ action: Action) { + model.sendAction(action) + } + + fileprivate init(_ model: Model) { + self.model = model + } + + fileprivate func setModel(_ newValue: Model) { + if !_$isIdentityEqual(model.state, newValue.state) { + _$observationRegistrar.withMutation(of: self, keyPath: \.state) { + model = newValue + } + } else { + model = newValue + } + } +} + +extension Store { + static func make(model: Model) -> (Store, (Model) -> Void) { + let store = Store(model) + return (store, store.setModel) + } + + subscript(dynamicMember keyPath: KeyPath) -> T { + state[keyPath: keyPath] + } + + func action(_ action: Action) -> () -> Void { + { self.send(action) } + } +} + +extension Store: Equatable { + public static func == (lhs: Store, rhs: Store) -> Bool { + lhs === rhs + } +} + +extension Store: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension Store: Identifiable {} + +#if canImport(Observation) +import Observation + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension Store: Observable {} +#endif diff --git a/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift new file mode 100644 index 000000000..e5fae93f9 --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/SwiftUIScreen.swift @@ -0,0 +1,67 @@ +#if canImport(UIKit) + +import ComposableArchitecture // for ObservableState +import SwiftUI +import Workflow +import WorkflowUI + +protocol SwiftUIScreen: Screen { + associatedtype State: ObservableState + associatedtype Action + associatedtype Content: View + + typealias Model = ViewModel + + var model: Model { get } + + @ViewBuilder + static func makeView(store: Store) -> Content +} + +extension SwiftUIScreen { + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: ModeledHostingController>.self, + environment: environment, + build: { + let (store, setModel) = Store.make(model: model) + return ModeledHostingController( + setModel: setModel, + rootView: EnvironmentInjectingView( + environment: environment, + content: Self.makeView(store: store) + ) + ) + }, + update: { hostingController in + hostingController.setModel(model) + // TODO: update viewEnvironment + } + ) + } +} + +private struct EnvironmentInjectingView: View { + var environment: ViewEnvironment + let content: Content + + var body: some View { + content + .environment(\.viewEnvironment, environment) + } +} + +private final class ModeledHostingController: UIHostingController { + let setModel: (Model) -> Void + + init(setModel: @escaping (Model) -> Void, rootView: Content) { + self.setModel = setModel + super.init(rootView: rootView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("not implemented") + } +} + +#endif diff --git a/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift new file mode 100644 index 000000000..27bd0427f --- /dev/null +++ b/Samples/SwiftUITestbed/Sources/Observation/ViewModel.swift @@ -0,0 +1,6 @@ +import ComposableArchitecture + +struct ViewModel { + let state: State + let sendAction: (Action) -> Void +}