From 718ec3e33d82046468fe076ebe55d595ee304a2b Mon Sep 17 00:00:00 2001 From: Jessalyn Wang Date: Mon, 24 Nov 2025 22:52:06 -0800 Subject: [PATCH 1/2] Add a recipe to show how presenters can be launched from iOS native and how we can render the models using SwiftUI rather than renderers. See #154 --- .../app/platform/recipes/AppComponent.kt | 8 ++ .../app/platform/recipes/DemoApplication.kt | 5 + .../recipes/swiftui/SwiftUiChildPresenter.kt | 43 ++++++++ .../recipes/swiftui/SwiftUiHomePresenter.kt | 65 +++++++++++ .../recipesIosApp/ComposeContentView.swift | 35 ------ .../recipesIosApp/ContentView.swift | 63 +++++++++++ .../AppPlatform+Extensions.swift | 103 ++++++++++++++++++ .../MoleculePresenterWrapper.swift | 31 ++++++ .../PresenterViews/PresenterModelView.swift | 31 ++++++ .../PresenterViews/PresenterView.swift | 59 ++++++++++ .../SelfRenderingViewModel.swift | 14 +++ .../recipesIosApp/RecipesIosApp.swift | 8 +- .../SwiftUI/SwiftUiChildPresenterView.swift | 30 +++++ .../SwiftUI/SwiftUiHomePresenterBuilder.swift | 24 ++++ .../SwiftUI/SwiftUiHomePresenterView.swift | 66 +++++++++++ .../SwiftUI/SwiftUiRootPresenterView.swift | 24 ++++ 16 files changed, 569 insertions(+), 40 deletions(-) create mode 100644 recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt create mode 100644 recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt delete mode 100644 recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/ContentView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift diff --git a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt index f80540f6..ce1127a7 100644 --- a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt +++ b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt @@ -2,6 +2,8 @@ package software.amazon.app.platform.recipes import me.tatarka.inject.annotations.IntoSet import me.tatarka.inject.annotations.Provides +import software.amazon.app.platform.presenter.molecule.MoleculeScopeFactory +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter import software.amazon.app.platform.scope.Scoped import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped import software.amazon.lastmile.kotlin.inject.anvil.AppScope @@ -28,4 +30,10 @@ interface AppComponent { * needed. */ @Provides @IntoSet @ForScope(AppScope::class) fun provideEmptyScoped(): Scoped = Scoped.NO_OP + + /** The root presenter for the SwiftUI recipe. */ + val swiftUiHomePresenter: SwiftUiHomePresenter + + /** Factory needed to launch presenters from native. */ + val moleculeScopeFactory: MoleculeScopeFactory } diff --git a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt index c7d42595..54de838d 100644 --- a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt +++ b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt @@ -4,6 +4,7 @@ import software.amazon.app.platform.scope.RootScopeProvider import software.amazon.app.platform.scope.Scope import software.amazon.app.platform.scope.coroutine.addCoroutineScopeScoped import software.amazon.app.platform.scope.di.addKotlinInjectComponent +import software.amazon.app.platform.scope.di.kotlinInjectComponent import software.amazon.app.platform.scope.register /** @@ -17,6 +18,10 @@ class DemoApplication : RootScopeProvider { override val rootScope: Scope get() = checkNotNull(_rootScope) { "Must call create() first." } + /** Provides the application scope DI component. */ + val appComponent: AppComponent + get() = rootScope.kotlinInjectComponent() + /** Creates the root scope and remembers the instance. */ fun create(appComponent: AppComponent) { check(_rootScope == null) { "create() should be called only once." } diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt new file mode 100644 index 00000000..621d1201 --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt @@ -0,0 +1,43 @@ +@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass") + +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.snapshots.SnapshotStateList +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import software.amazon.app.platform.presenter.BaseModel +import software.amazon.app.platform.presenter.molecule.MoleculePresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiChildPresenter.Model + +class SwiftUiChildPresenter( + private val index: Int, + private val backstack: SnapshotStateList>, +) : MoleculePresenter { + @Composable + override fun present(input: Unit): Model { + val counter by + produceState(0) { + while (isActive) { + delay(1.seconds) + value += 1 + } + } + + return Model(index = index, counter = counter) { + when (it) { + Event.AddPeer -> + backstack.add(SwiftUiChildPresenter(index = index + 1, backstack = backstack)) + } + } + } + + data class Model(val index: Int, val counter: Int, val onEvent: (Event) -> Unit) : BaseModel + + sealed interface Event { + data object AddPeer : Event + } +} diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt new file mode 100644 index 00000000..8925bcfa --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt @@ -0,0 +1,65 @@ +@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass") + +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import me.tatarka.inject.annotations.Inject +import software.amazon.app.platform.presenter.BaseModel +import software.amazon.app.platform.presenter.molecule.MoleculePresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model + +/** + * A presenter that manages a backstack of presenters that are rendered by SwiftUI's + * `NavigationStack`. All presenters in this backstack are always active, because `NavigationStack` + * renders them on stack modification. In SwiftUI this is necessary as views remain alive even when + * they are no longer visible. + * + * A detail of note for this class is that we pass a list of [BaseModel] to the view but receive a + * list of [Int] back where each integer represents the position of a presenter in the backstack + * list. This is because to share control of state with `NavigationStack` we need to initialize the + * `NavigationStack` with a `Binding` to a collection of `Hashable` data values. [BaseModel] by + * default is not `Hashable` and we cannot extend it to conform to `Hashable` due to current + * Kotlin-Swift interop limitations. As such in Swift the list of [BaseModel] is converted to a list + * of indices, which are hashable by default. This should be sufficient to handle most navigation + * cases but if it is required to receive more information to determine how to modify the presenter + * backstack, it is possible to create a generic class that implements [BaseModel] and wrap that + * class in a hashable `struct`. + */ +@Inject +class SwiftUiHomePresenter : MoleculePresenter { + @Composable + override fun present(input: Unit): Model { + val backstack = remember { + mutableStateListOf>().apply { + // There must be always one element. + add(SwiftUiChildPresenter(index = 0, backstack = this)) + } + } + + return Model(modelBackstack = backstack.map { it.present(Unit) }) { + when (it) { + is Event.BackstackModificationEvent -> { + val updatedBackstack = it.indicesBackstack.map { index -> backstack[index] } + + backstack.clear() + backstack.addAll(updatedBackstack) + } + } + } + } + + /** + * Model that contains all the information needed for SwiftUI to render the backstack. + * [modelBackstack] contains the backage and [onEvent] exposes an event handling function that can + * be called by the binding that `NavigationStack` is initialized with. + */ + data class Model(val modelBackstack: List, val onEvent: (Event) -> Unit) : BaseModel + + /** All events that [SwiftUiHomePresenter] can process. */ + sealed interface Event { + /** Sent when `NavigationStack` has modified its stack. */ + data class BackstackModificationEvent(val indicesBackstack: List) : Event + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift b/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift deleted file mode 100644 index 5eb518ff..00000000 --- a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ComposeContentView.swift -// recipesIosApp -// -// Created by Wang, Jessalyn on 11/10/25. -// - -import SwiftUI -import RecipesApp - -struct ComposeView: UIViewControllerRepresentable { - private var rootScopeProvider: RootScopeProvider - - init(rootScopeProvider: RootScopeProvider) { - self.rootScopeProvider = rootScopeProvider - } - - func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -struct ComposeContentView: View { - var rootScopeProvider: RootScopeProvider - - init(rootScopeProvider: RootScopeProvider) { - self.rootScopeProvider = rootScopeProvider - } - - var body: some View { - ComposeView(rootScopeProvider: rootScopeProvider).ignoresSafeArea(.keyboard) // Compose has its own keyboard handler - } -} diff --git a/recipes/recipesIosApp/recipesIosApp/ContentView.swift b/recipes/recipesIosApp/recipesIosApp/ContentView.swift new file mode 100644 index 00000000..d2b2a26b --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/ContentView.swift @@ -0,0 +1,63 @@ +// +// ContentView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/10/25. +// + +import SwiftUI +import RecipesApp + +struct ComposeView: UIViewControllerRepresentable { + private var rootScopeProvider: RootScopeProvider + + init(rootScopeProvider: RootScopeProvider) { + self.rootScopeProvider = rootScopeProvider + } + + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var appDelegate: AppDelegate + + @State var showComposeRecipes = false + @State var showSwiftUIRecipe = false + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + var body: some View { + VStack { + Spacer() + + Button(action: { showComposeRecipes.toggle() }) { + Text("CMP-rendered recipes") + } + .buttonStyle(.borderedProminent) + + Spacer() + + Button(action: { showSwiftUIRecipe.toggle() }) { + Text("SwiftUI recipe") + } + .buttonStyle(.borderedProminent) + + Spacer() + } + .sheet(isPresented: $showComposeRecipes) { + ComposeView(rootScopeProvider: appDelegate) + .ignoresSafeArea(.keyboard) // Compose has its own keyboard handler + } + .sheet(isPresented: $showSwiftUIRecipe) { + SwiftUiRootPresenterView( + homePresenter: SwiftUiHomePresenterBuilder(appDelegate: appDelegate).makeHomePresenter() + ) + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift new file mode 100644 index 00000000..951b7bc0 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift @@ -0,0 +1,103 @@ +// +// AppPlatform+Extensions.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp + +extension Presenter { + /// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`. + func viewModels(ofType type: Model.Type) -> AsyncThrowingStream { + model + .values() + .compactMap { $0 as? Model } + .asAsyncThrowingStream() + } + +} + +enum KotlinFlowError { + case unexpectedValueInKotlinFlow(value: Any, expectedType: String) +} + +extension Kotlinx_coroutines_coreFlow { + + /// Returns an async sequence of Any? from the Kotlin Flow. + /// + /// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check. + /// You can use `valuesOfType` instead which returns a stream that throws an error if the values are not of the right type. + /// `valuesOfType` is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent. + func values() -> AsyncThrowingStream { + let collector = Kotlinx_coroutines_coreFlowCollectorImpl() + collect(collector: collector, completionHandler: collector.onComplete(_:)) + return collector.values + } + + /// Returns an async sequence from the Kotlin Flow. + /// + /// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check. + /// If the Flow sends the right type, this stream will throw an error. + /// This is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent. + func valuesOfType(_ type: T.Type = T.self) -> AsyncThrowingStream { + let collector = Kotlinx_coroutines_coreFlowCollectorImpl() + Task { @MainActor in + do { + try await collect(collector: collector) + collector.onComplete(nil) + } catch { + collector.onComplete(error) + } + } + return collector.values + } + +} + +fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl: Kotlinx_coroutines_coreFlowCollector { + + let values: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + + init() { + let (values, continuation) = AsyncThrowingStream.makeStream() + self.values = values + self.continuation = continuation + } + + func emit(value: Any?) async throws { + if let castedValue = value as? Value { + continuation.yield(castedValue) + } + } + + func onComplete(_ error: Error?) { + continuation.finish(throwing: error) + } + + deinit { + print("Deiniting collector") + } +} + +extension AsyncSequence { + + func asAsyncThrowingStream() -> AsyncThrowingStream { + if let self = self as? AsyncThrowingStream { + return self + } + var asyncIterator = self.makeAsyncIterator() + return AsyncThrowingStream { + try await asyncIterator.next() + } + } + +} + +extension Int { + /// Converts Swift Int to Kotlin's Int type for interop. + func toKotlinInt() -> KotlinInt { + return KotlinInt(integerLiteral: self) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift new file mode 100644 index 00000000..cf6467b7 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift @@ -0,0 +1,31 @@ +// +// MoleculePresenterWrapper.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp + +/// Wraps a Molecule Presenter that has been converted into a regular Presenter. +/// +/// In order to convert a Molecule Presenter to a regular Presenter, we need to create a MoleculeScope, +/// and that scope needs to be cancelled when we are done, +/// so we create this class which will automatically cancel the scope upon deinit. +class MoleculePresenterWrapper: Presenter { + var model: Kotlinx_coroutines_coreStateFlow { wrapped.model } + + private let wrapped: Presenter + private let scope: MoleculeScope + + init(moleculeScopeFactory: MoleculeScopeFactory, moleculePresenter: MoleculePresenter, input: Any) { + let scope = moleculeScopeFactory.createMoleculeScope() + self.scope = scope + self.wrapped = scope.launchMoleculePresenter(presenter: moleculePresenter, input: input) + } + + deinit { + scope.cancel() + } + +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift new file mode 100644 index 00000000..97406452 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift @@ -0,0 +1,31 @@ +// +// PresenterModelView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI + +/// A generic view that renders `Models` conforming to `SelfRenderingViewModel`. +/// +/// This view is a lightweight wrapper that delegates view creation to the `Models` themselves. `Models` must conform +/// to `SelfRenderingViewModel` and provide their view in `makeViewRenderer()`. This is a simple API that +/// enables view creation for models regardless of type. If needed this implementation can be changed to follow a factory pattern. +struct PresenterModelView: View { + var model: Model + + init(model: Model) { + self.model = model + } + + var body: some View { + let type = type(of: model as Any) + + if let selfRenderingModel = model as? (any SelfRenderingViewModel) { + return AnyView(selfRenderingModel.makeViewRenderer()) + } + + fatalError("Could not find view builder for \(type). Make \(type) conform to `SelfRenderingViewModel` protocol.") + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift new file mode 100644 index 00000000..72a92700 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift @@ -0,0 +1,59 @@ +// +// PresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI +import RecipesApp + +/// Displays the view model hieararchy from a root `Presenter`. +/// +/// `PresenterView` can be instantiated by passing in a Kotlin `Presenter` or an `AsyncSequence` of `ViewModels`. +/// +/// Note that `PresenterView` should not be used often. `Presenters` are hierarchical, with parent `Presenters` containing the models of their children. +/// The view for a parent `Presenter` model should also present the model of its children, so `PresenterView` is only be needed for the root +/// parent `Presenter`. +struct PresenterView: View { + @StateObject var viewModelObserver: ViewModelObserver + + init(presenter: Presenter, viewModelType: Model.Type, handleViewModelError: @escaping (Error) -> ()) { + self.init(viewModels: presenter.viewModels(ofType: viewModelType), handleViewModelError: handleViewModelError) + } + + init(viewModels: ViewModels, handleViewModelError: @escaping (Error) -> ()) where ViewModels.Element == Model { + self._viewModelObserver = StateObject(wrappedValue: ViewModelObserver( + viewModels: viewModels, + handleError: handleViewModelError + )) + } + + var body: some View { + if let viewModel = viewModelObserver.viewModel { + PresenterModelView(model: viewModel) + } + } + + @MainActor + class ViewModelObserver: ObservableObject { + @Published var viewModel: Model? + private var task: Task? = nil + + init(viewModels: ViewModels, handleError: @escaping (Error) -> ()) where ViewModels.Element == Model { + task = Task { @MainActor [weak self] in + do { + for try await viewModel in viewModels { + self?.viewModel = viewModel + } + } catch { + handleError(error) + } + } + } + + deinit { + task?.cancel() + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift new file mode 100644 index 00000000..1fd1c48a --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift @@ -0,0 +1,14 @@ +// +// SelfRenderingViewModel.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI + +/// A protocol for view models that create their own SwiftUI view representation. +protocol SelfRenderingViewModel { + associatedtype Renderer : View + @ViewBuilder @MainActor func makeViewRenderer() -> Self.Renderer +} diff --git a/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift b/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift index e775f514..8a44262d 100644 --- a/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift +++ b/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift @@ -9,12 +9,10 @@ import SwiftUI import RecipesApp class AppDelegate: NSObject, UIApplicationDelegate, RootScopeProvider { - private let demoApplication: DemoApplication = DemoApplication() + let demoApplication: DemoApplication = DemoApplication() var rootScope: Scope { - get { - demoApplication.rootScope - } + get { demoApplication.rootScope } } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { @@ -30,7 +28,7 @@ struct recipesIosApp: App { var body: some Scene { WindowGroup { - ComposeContentView(rootScopeProvider: appDelegate) + ContentView(appDelegate: appDelegate) } } } diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift new file mode 100644 index 00000000..9cd1d4f8 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift @@ -0,0 +1,30 @@ +// +// SwiftUiChildPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import RecipesApp +import SwiftUI + +extension SwiftUiChildPresenter.Model: SelfRenderingViewModel { + func makeViewRenderer() -> some View { + SwiftUiChildPresenterView(model: self) + } +} + +struct SwiftUiChildPresenterView: View { + var model: SwiftUiChildPresenter.Model + + var body: some View { + Text("Index: \(model.index)") + .font(.system(size: 36)) + Text("Counter: \(model.counter)") + .font(.system(size: 36)) + Button(action: { model.onEvent(SwiftUiChildPresenterEventAddPeer()) }) { + Text("Add peer") + } + .buttonStyle(.borderedProminent) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift new file mode 100644 index 00000000..3965205e --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift @@ -0,0 +1,24 @@ +// +// SwiftUiHomePresenterBuilder.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp + +struct SwiftUiHomePresenterBuilder { + private let appComponent: AppComponent + + init(appDelegate: AppDelegate) { + self.appComponent = appDelegate.demoApplication.appComponent + } + + func makeHomePresenter() -> Presenter { + MoleculePresenterWrapper( + moleculeScopeFactory: appComponent.moleculeScopeFactory, + moleculePresenter: appComponent.swiftUiHomePresenter, + input: Void() + ) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift new file mode 100644 index 00000000..413ca3d3 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift @@ -0,0 +1,66 @@ +// +// SwiftUiHomePresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI +import RecipesApp + +extension SwiftUiHomePresenter.Model: SelfRenderingViewModel { + + func makeViewRenderer() -> some View { + SwiftUiHomePresenterView(model: self) + } +} + +// Creates a binding for the workflow presenter model backstack so we can provide it to +// NavigationStack. The backstack is indexed here as the type of the Binding needs to be hashable. +// SwiftUiHomePresenter.Model accepts a modified list of indices +extension SwiftUiHomePresenter.Model { + func pathBinding() -> Binding<[Int]> { + .init { + // drop the first value of the backstack from the path because that should be the root view + Array(self.modelBackstack.indices.dropFirst()) + } set: { modifiedIndices in + + // the resulting backstack indices the presenter should compute on is the first index (0) that was + // dropped as well as the remaining indices post modification + let indicesBackstack = [0] + modifiedIndices.map { $0.toKotlinInt() } + + self.onEvent( + SwiftUiHomePresenterEventBackstackModificationEvent ( + indicesBackstack: indicesBackstack + ) + ) + } + } +} + +struct SwiftUiHomePresenterView: View { + var model: SwiftUiHomePresenter.Model + + var body: some View { + NavigationStackView(model: self.model) + } +} + +private struct NavigationStackView: View { + var backstack: [BaseModel] + var model: SwiftUiHomePresenter.Model + + init(model: SwiftUiHomePresenter.Model) { + self.backstack = model.modelBackstack + self.model = model + } + + var body: some View { + NavigationStack(path: model.pathBinding()) { + PresenterModelView(model: backstack[0]) + .navigationDestination(for: Int.self) { index in + PresenterModelView(model: backstack[index]) + } + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift new file mode 100644 index 00000000..cb420032 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift @@ -0,0 +1,24 @@ +// +// SwiftUiRootPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import SwiftUI +import RecipesApp + +/// Creates the view model hierarchy for the root presenter of this recipe, `SwiftUiHomePresenter`. +struct SwiftUiRootPresenterView: View { + var homePresenter: Presenter + + var body: some View { + PresenterView( + presenter: homePresenter, + viewModelType: BaseModel.self, + handleViewModelError: { error in + fatalError("View model error occured: \(error)") + } + ) + } +} From 05b87f26d1bced93ba0db8e12d925c94b0d816c1 Mon Sep 17 00:00:00 2001 From: Jessalyn Wang Date: Tue, 25 Nov 2025 11:24:53 -0800 Subject: [PATCH 2/2] Rename SelfRenderingViewModel and remove unnecessary PresenterModelView API --- .../AppPlatform+Extensions.swift | 22 +++++++++++-- .../PresenterViews/PresenterModelView.swift | 31 ------------------- .../PresenterViews/PresenterView.swift | 4 +-- ...ewModel.swift => PresenterViewModel.swift} | 5 +-- .../SwiftUI/SwiftUiChildPresenterView.swift | 2 +- .../SwiftUI/SwiftUiHomePresenterView.swift | 6 ++-- 6 files changed, 28 insertions(+), 42 deletions(-) delete mode 100644 recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift rename recipes/recipesIosApp/recipesIosApp/PresenterViews/{SelfRenderingViewModel.swift => PresenterViewModel.swift} (78%) diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift index 951b7bc0..5c1b1ff8 100644 --- a/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift @@ -6,6 +6,7 @@ // import RecipesApp +import SwiftUI extension Presenter { /// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`. @@ -15,7 +16,6 @@ extension Presenter { .compactMap { $0 as? Model } .asAsyncThrowingStream() } - } enum KotlinFlowError { @@ -52,7 +52,6 @@ extension Kotlinx_coroutines_coreFlow { } return collector.values } - } fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl: Kotlinx_coroutines_coreFlowCollector { @@ -92,7 +91,6 @@ extension AsyncSequence { try await asyncIterator.next() } } - } extension Int { @@ -101,3 +99,21 @@ extension Int { return KotlinInt(integerLiteral: self) } } + +extension BaseModel { + /// Gets the view for some `BaseModel` + /// + /// Returns. created by `makeViewRenderer()` if a model conforms to `PresenterViewModel` otherwise, crash the build for + /// debug builds or return a default view. + @MainActor func getViewRenderer() -> AnyView { + guard let viewModel = self as? (any PresenterViewModel) else { + assertionFailure("ViewModel \(self) does not conform to `PresenterViewModel`") + + // This is an implementation detail. If crashing is preferred even in production builds, `fatalError(..)` + // can be used instead + return AnyView(Text("Error, some ViewModel was not implemented!")) + } + + return AnyView(viewModel.makeViewRenderer()) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift deleted file mode 100644 index 97406452..00000000 --- a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterModelView.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// PresenterModelView.swift -// recipesIosApp -// -// Created by Wang, Jessalyn on 11/24/25. -// - -import SwiftUI - -/// A generic view that renders `Models` conforming to `SelfRenderingViewModel`. -/// -/// This view is a lightweight wrapper that delegates view creation to the `Models` themselves. `Models` must conform -/// to `SelfRenderingViewModel` and provide their view in `makeViewRenderer()`. This is a simple API that -/// enables view creation for models regardless of type. If needed this implementation can be changed to follow a factory pattern. -struct PresenterModelView: View { - var model: Model - - init(model: Model) { - self.model = model - } - - var body: some View { - let type = type(of: model as Any) - - if let selfRenderingModel = model as? (any SelfRenderingViewModel) { - return AnyView(selfRenderingModel.makeViewRenderer()) - } - - fatalError("Could not find view builder for \(type). Make \(type) conform to `SelfRenderingViewModel` protocol.") - } -} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift index 72a92700..25e518f5 100644 --- a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift @@ -15,7 +15,7 @@ import RecipesApp /// Note that `PresenterView` should not be used often. `Presenters` are hierarchical, with parent `Presenters` containing the models of their children. /// The view for a parent `Presenter` model should also present the model of its children, so `PresenterView` is only be needed for the root /// parent `Presenter`. -struct PresenterView: View { +struct PresenterView: View { @StateObject var viewModelObserver: ViewModelObserver init(presenter: Presenter, viewModelType: Model.Type, handleViewModelError: @escaping (Error) -> ()) { @@ -31,7 +31,7 @@ struct PresenterView: View { var body: some View { if let viewModel = viewModelObserver.viewModel { - PresenterModelView(model: viewModel) + viewModel.getViewRenderer() } } diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift similarity index 78% rename from recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift rename to recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift index 1fd1c48a..2d73c349 100644 --- a/recipes/recipesIosApp/recipesIosApp/PresenterViews/SelfRenderingViewModel.swift +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift @@ -1,14 +1,15 @@ // -// SelfRenderingViewModel.swift +// PresenterViewModel.swift // recipesIosApp // // Created by Wang, Jessalyn on 11/24/25. // import SwiftUI +import RecipesApp /// A protocol for view models that create their own SwiftUI view representation. -protocol SelfRenderingViewModel { +protocol PresenterViewModel { associatedtype Renderer : View @ViewBuilder @MainActor func makeViewRenderer() -> Self.Renderer } diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift index 9cd1d4f8..4ecf68df 100644 --- a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift @@ -8,7 +8,7 @@ import RecipesApp import SwiftUI -extension SwiftUiChildPresenter.Model: SelfRenderingViewModel { +extension SwiftUiChildPresenter.Model: PresenterViewModel { func makeViewRenderer() -> some View { SwiftUiChildPresenterView(model: self) } diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift index 413ca3d3..3ddf676c 100644 --- a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift @@ -8,7 +8,7 @@ import SwiftUI import RecipesApp -extension SwiftUiHomePresenter.Model: SelfRenderingViewModel { +extension SwiftUiHomePresenter.Model: PresenterViewModel { func makeViewRenderer() -> some View { SwiftUiHomePresenterView(model: self) @@ -57,9 +57,9 @@ private struct NavigationStackView: View { var body: some View { NavigationStack(path: model.pathBinding()) { - PresenterModelView(model: backstack[0]) + backstack[0].getViewRenderer() .navigationDestination(for: Int.self) { index in - PresenterModelView(model: backstack[index]) + backstack[index].getViewRenderer() } } }