diff --git a/Samples/SwiftUITestbed/Sources/AppDelegate.swift b/Samples/SwiftUITestbed/Sources/AppDelegate.swift index 38c48e2df..89204be39 100644 --- a/Samples/SwiftUITestbed/Sources/AppDelegate.swift +++ b/Samples/SwiftUITestbed/Sources/AppDelegate.swift @@ -25,7 +25,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = WorkflowHostingController( - workflow: RootWorkflow(close: nil) + workflow: RootWorkflow() .mapRendering(MarketRootScreen.init) .mapRendering(ModalHostContainer.init) ) diff --git a/Samples/SwiftUITestbed/Sources/MainScreen.swift b/Samples/SwiftUITestbed/Sources/MainScreen.swift index 2ea124462..5f97c988b 100644 --- a/Samples/SwiftUITestbed/Sources/MainScreen.swift +++ b/Samples/SwiftUITestbed/Sources/MainScreen.swift @@ -19,7 +19,7 @@ import MarketUI import MarketWorkflowUI import ViewEnvironment -struct MainScreen: MarketScreen { +struct MainScreen: MarketScreen, Equatable { enum Field: Hashable { case title } @@ -27,16 +27,11 @@ struct MainScreen: MarketScreen { @FocusState var focusedField: Field? let title: String - let didChangeTitle: (String) -> Void - + let canClose: Bool let allCapsToggleIsOn: Bool let allCapsToggleIsEnabled: Bool - let didChangeAllCapsToggle: (Bool) -> Void - - let didTapPushScreen: () -> Void - let didTapPresentScreen: () -> Void - let didTapClose: (() -> Void)? + let sink: StableSink func element( in context: MarketWorkflowUI.MarketScreenContext, @@ -56,7 +51,7 @@ struct MainScreen: MarketScreen { style: styles.fields.textField, label: "Text", text: title, - onChange: didChangeTitle, + onChange: { sink.send(.changeTitle($0)) }, onReturn: { _ in focusedField = nil } ) .focused(when: $focusedField, equals: .title) @@ -77,7 +72,7 @@ struct MainScreen: MarketScreen { isOn: allCapsToggleIsOn, isEnabled: allCapsToggleIsEnabled, accessibilityLabel: "is all caps", - onChange: didChangeAllCapsToggle + onChange: { sink.send(.changeAllCaps($0)) } ) } @@ -91,13 +86,13 @@ struct MainScreen: MarketScreen { MarketButton( style: styles.button(rank: .secondary), text: "Push Screen", - onTap: didTapPushScreen + onTap: sink.closure(.pushScreen) ) MarketButton( style: styles.button(rank: .secondary), text: "Present Screen", - onTap: didTapPresentScreen + onTap: sink.closure(.presentScreen) ) } } @@ -108,9 +103,26 @@ extension MainScreen: MarketBackStackContentScreen { func backStackItem(in environment: ViewEnvironment) -> MarketUI.MarketNavigationItem { MarketNavigationItem( title: .text(.init(regular: title)), - backButton: didTapClose.map { .close(onTap: $0) } ?? .automatic() + backButton: canClose ? .close(onTap: sink.closure(.close)) : .automatic() ) } var backStackIdentifier: AnyHashable? { nil } } + +extension MainScreen { + enum Action { + case pushScreen + case presentScreen + case changeTitle(String) + case changeAllCaps(Bool) + case close + } +} + +// I guess this could be upstreamed to Blueprint +extension FocusState: Equatable where Value: Equatable { + public static func == (lhs: FocusState, rhs: FocusState) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} diff --git a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift index d8b0e5ae0..7e04640a4 100644 --- a/Samples/SwiftUITestbed/Sources/MainWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/MainWorkflow.swift @@ -18,16 +18,18 @@ import MarketWorkflowUI import Workflow struct MainWorkflow: Workflow { - let didClose: (() -> Void)? + let canClose: Bool enum Output { case pushScreen case presentScreen + case close } struct State { var title: String var isAllCaps: Bool + let trampoline = SinkTrampoline() init(title: String) { self.title = title @@ -39,49 +41,44 @@ struct MainWorkflow: Workflow { State(title: "New item") } - enum Action: WorkflowAction { - typealias WorkflowType = MainWorkflow - - case pushScreen - case presentScreen - case changeTitle(String) - case changeAllCaps(Bool) - - func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { - switch self { - case .pushScreen: - return .pushScreen - case .presentScreen: - return .presentScreen - case .changeTitle(let newValue): - state.title = newValue - state.isAllCaps = newValue.isAllCaps - case .changeAllCaps(let isAllCaps): - state.isAllCaps = isAllCaps - state.title = isAllCaps ? state.title.uppercased() : state.title.lowercased() - } - return nil - } - } - typealias Rendering = MainScreen + typealias Action = MainScreen.Action func render(state: State, context: RenderContext) -> Rendering { - let sink = context.makeSink(of: Action.self) + let sink = state.trampoline.makeSink(of: Action.self, with: context) return MainScreen( title: state.title, - didChangeTitle: { sink.send(.changeTitle($0)) }, + canClose: canClose, allCapsToggleIsOn: state.isAllCaps, allCapsToggleIsEnabled: !state.title.isEmpty, - didChangeAllCapsToggle: { sink.send(.changeAllCaps($0)) }, - didTapPushScreen: { sink.send(.pushScreen) }, - didTapPresentScreen: { sink.send(.presentScreen) }, - didTapClose: didClose + sink: sink ) } } +extension MainScreen.Action: WorkflowAction { + typealias WorkflowType = MainWorkflow + + func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { + switch self { + case .pushScreen: + return .pushScreen + case .presentScreen: + return .presentScreen + case .changeTitle(let newValue): + state.title = newValue + state.isAllCaps = newValue.isAllCaps + case .changeAllCaps(let isAllCaps): + state.isAllCaps = isAllCaps + state.title = isAllCaps ? state.title.uppercased() : state.title.lowercased() + case .close: + return .close + } + return nil + } +} + private extension String { var isAllCaps: Bool { allSatisfy { character in @@ -89,3 +86,51 @@ private extension String { } } } + +class SinkTrampoline: Equatable { + private var sinks: [ObjectIdentifier: Any] = [:] + + func makeSink( + of actionType: Action.Type, + with context: RenderContext + ) -> StableSink where Action: WorkflowAction, Action.WorkflowType == WorkflowType { + let sink = context.makeSink(of: actionType) + + sinks[ObjectIdentifier(actionType)] = sink + + return StableSink(trampoline: self) + } + + func bounce(action: Action) { + let sink = destination(for: Action.self) + sink.send(action) + } + + private func destination(for actionType: Action.Type) -> Sink { + if let pipe = sinks[ObjectIdentifier(actionType)] { + return pipe as! Sink + } + fatalError("bad plumbing") + } + + static func == (lhs: SinkTrampoline, rhs: SinkTrampoline) -> Bool { + lhs === rhs + } +} + +struct StableSink: Equatable { + private var trampoline: SinkTrampoline + + init(trampoline: SinkTrampoline) { + self.trampoline = trampoline + } + + func send(_ action: Action) { + trampoline.bounce(action: action) + } + + // sugar instead of writing { sink.send($0) } + func closure(_ action: Action) -> () -> Void { + { send(action) } + } +} diff --git a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift index 30db6a875..6e2117b5c 100644 --- a/Samples/SwiftUITestbed/Sources/RootWorkflow.swift +++ b/Samples/SwiftUITestbed/Sources/RootWorkflow.swift @@ -19,9 +19,9 @@ import Workflow import WorkflowUI struct RootWorkflow: Workflow { - let close: (() -> Void)? - - typealias Output = Never + enum Output { + case close + } struct State { var backStack: BackStack @@ -57,6 +57,8 @@ struct RootWorkflow: Workflow { state.backStack.other.append(.main()) case .main(.presentScreen): state.isPresentingModal = true + case .main(.close): + return .close case .popScreen: state.backStack.other.removeLast() case .dismissScreen: @@ -75,7 +77,7 @@ struct RootWorkflow: Workflow { func rendering(_ screen: State.Screen, isRoot: Bool) -> AnyMarketBackStackContentScreen { switch screen { case .main(let id): - return MainWorkflow(didClose: isRoot ? close : nil) + return MainWorkflow(canClose: isRoot) .mapOutput(Action.main) .mapRendering(AnyMarketBackStackContentScreen.init) .rendered(in: context, key: id.uuidString) @@ -96,7 +98,13 @@ struct RootWorkflow: Workflow { base: backStack, modals: { guard state.isPresentingModal else { return [] } - let screen = RootWorkflow(close: { sink.send(.dismissScreen) }) + let screen = RootWorkflow() + .mapOutput { output in + switch output { + case .close: + return Action.dismissScreen + } + } .rendered(in: context) .asAnyScreen() let modal = Modal(