From a21edb63ec65f7c4de5ed20d61841ae563b21aaa Mon Sep 17 00:00:00 2001 From: Jessalyn Wang Date: Mon, 24 Nov 2025 11:25:33 -0800 Subject: [PATCH] DNM: Launch a SwiftUI view from LandingPresenter --- recipes/app/build.gradle | 3 + .../platform/recipes/MainViewController.kt | 21 +++++- recipes/common/impl/build.gradle | 15 +++- .../recipes/landing/LandingPresenter.kt | 11 +++ .../recipes/landing/LandingRenderer.kt | 6 ++ .../recipes/swiftui/SwiftUiChildPresenter.kt | 45 ++++++++++++ .../recipes/swiftui/SwiftUiHomePresenter.kt | 68 +++++++++++++++++++ .../swiftui/IosNoOpSwiftUiHomeRenderer.kt | 22 ++++++ .../swiftui/CommonSwiftUiHomeRenderer.kt | 26 +++++++ .../recipesIosApp/ComposeContentView.swift | 30 +++++++- .../Counter/CounterChildPresenterView.swift | 7 ++ .../Counter/CounterRootPresenterView.swift | 22 ++++++ .../Counter/CounterViewController.swift | 41 +++++++++++ 13 files changed, 312 insertions(+), 5 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 create mode 100644 recipes/common/impl/src/iosMain/kotlin/software/amazon/app/platform/recipes/swiftui/IosNoOpSwiftUiHomeRenderer.kt create mode 100644 recipes/common/impl/src/noIosMain/kotlin/software/amazon/app/platform/recipes/swiftui/CommonSwiftUiHomeRenderer.kt create mode 100644 recipes/recipesIosApp/recipesIosApp/Counter/CounterChildPresenterView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/Counter/CounterRootPresenterView.swift create mode 100644 recipes/recipesIosApp/recipesIosApp/Counter/CounterViewController.swift diff --git a/recipes/app/build.gradle b/recipes/app/build.gradle index 289b0453..faa84afa 100644 --- a/recipes/app/build.gradle +++ b/recipes/app/build.gradle @@ -21,5 +21,8 @@ appPlatform { dependencies { commonMainImplementation project(':recipes:common:impl') + //noinspection UseTomlInstead + commonMainImplementation("co.touchlab:kermit:2.0.4") + androidMainImplementation libs.androidx.activity.compose } diff --git a/recipes/app/src/iosMain/kotlin/software/amazon/app/platform/recipes/MainViewController.kt b/recipes/app/src/iosMain/kotlin/software/amazon/app/platform/recipes/MainViewController.kt index f032b0a5..f6762f73 100644 --- a/recipes/app/src/iosMain/kotlin/software/amazon/app/platform/recipes/MainViewController.kt +++ b/recipes/app/src/iosMain/kotlin/software/amazon/app/platform/recipes/MainViewController.kt @@ -5,7 +5,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController +import co.touchlab.kermit.Logger import platform.UIKit.UIViewController +import software.amazon.app.platform.presenter.BaseModel +import software.amazon.app.platform.recipes.backstack.CrossSlideBackstackPresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter +import software.amazon.app.platform.recipes.template.RecipesAppTemplate import software.amazon.app.platform.renderer.ComposeRendererFactory import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.scope.RootScopeProvider @@ -19,7 +24,7 @@ import software.amazon.app.platform.scope.di.kotlinInjectComponent * good enough for the iOS recipes app. */ @Suppress("unused") -fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController = +fun mainViewController(rootScopeProvider: RootScopeProvider, renderSwiftUi: (BaseModel) -> Unit): UIViewController = ComposeUIViewController { // Create a single instance. val templateProvider = remember { @@ -42,6 +47,16 @@ fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController = // Render templates using our Renderer runtime. val template by templateProvider.templates.collectAsState() - val renderer = factory.getRenderer(template::class) - renderer.renderCompose(template) + // TODO: do something about this..... + val swiftUiModel = + ((template as? RecipesAppTemplate.FullScreenTemplate)?.model + as? CrossSlideBackstackPresenter.Model)?.delegate as? SwiftUiHomePresenter.Model + + if (swiftUiModel == null) { + val renderer = factory.getRenderer(template::class) + + renderer.renderCompose(template) + } else { + renderSwiftUi(swiftUiModel) + } } diff --git a/recipes/common/impl/build.gradle b/recipes/common/impl/build.gradle index af437ce8..82fe694a 100644 --- a/recipes/common/impl/build.gradle +++ b/recipes/common/impl/build.gradle @@ -16,15 +16,25 @@ appPlatform { kotlin { def noAndroid = sourceSets.create("noAndroidMain") + def noIos = sourceSets.create("noIosMain") sourceSets.named('commonMain').configure { noAndroid.dependsOn(it) + noIos.dependsOn(it) } - sourceSets.named('appleAndDesktopMain').configure { + sourceSets.named('appleMain').configure { it.dependsOn(noAndroid) } sourceSets.named('wasmJsMain').configure { it.dependsOn(noAndroid) + it.dependsOn(noIos) + } + sourceSets.named('desktopMain').configure { + it.dependsOn(noAndroid) + it.dependsOn(noIos) + } + sourceSets.named('androidMain').configure { + it.dependsOn(noIos) } } @@ -35,6 +45,9 @@ dependencies { commonMainImplementation compose.runtimeSaveable commonMainImplementation compose.ui + //noinspection UseTomlInstead + commonMainImplementation("co.touchlab:kermit:2.0.4") + commonMainImplementation libs.androidx.collection androidMainImplementation libs.navigation3.runtime diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingPresenter.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingPresenter.kt index 707a2149..c128c9ee 100644 --- a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingPresenter.kt +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingPresenter.kt @@ -1,6 +1,7 @@ package software.amazon.app.platform.recipes.landing import androidx.compose.runtime.Composable +import co.touchlab.kermit.Logger import me.tatarka.inject.annotations.Inject import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.presenter.molecule.MoleculePresenter @@ -9,6 +10,7 @@ import software.amazon.app.platform.recipes.backstack.LocalBackstackScope import software.amazon.app.platform.recipes.backstack.presenter.BackstackChildPresenter import software.amazon.app.platform.recipes.landing.LandingPresenter.Model import software.amazon.app.platform.recipes.nav3.Navigation3HomePresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter /** The presenter that is responsible to show the content of the landing page in the Recipes app. */ @Inject @@ -17,6 +19,8 @@ class LandingPresenter : MoleculePresenter { override fun present(input: Unit): Model { val backstack = checkNotNull(LocalBackstackScope.current) + Logger.i { "Hello World" } + return Model { when (it) { Event.AddPresenterToBackstack -> { @@ -30,6 +34,10 @@ class LandingPresenter : MoleculePresenter { Event.Navigation3 -> { backstack.push(Navigation3HomePresenter()) } + + Event.SwiftUI -> { + backstack.push(SwiftUiHomePresenter()) + } } } } @@ -50,5 +58,8 @@ class LandingPresenter : MoleculePresenter { /** Show the presenter highlighting navigation3 integration. */ data object Navigation3 : Event + + /** Show the presenter highlighting SwiftUI integration. */ + data object SwiftUI : Event } } diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingRenderer.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingRenderer.kt index 53b793bd..9e10764b 100644 --- a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingRenderer.kt +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingRenderer.kt @@ -37,6 +37,12 @@ class LandingRenderer : ComposeRenderer() { ) { Text("Navigation3") } + Button( + onClick = { model.onEvent(LandingPresenter.Event.SwiftUI) }, + modifier = Modifier.padding(top = 12.dp), + ) { + Text("SwiftUI") + } } } } 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..d3f9f581 --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt @@ -0,0 +1,45 @@ +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 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 +import kotlin.time.Duration.Companion.seconds + +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..d2ab1f00 --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt @@ -0,0 +1,68 @@ +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/common/impl/src/iosMain/kotlin/software/amazon/app/platform/recipes/swiftui/IosNoOpSwiftUiHomeRenderer.kt b/recipes/common/impl/src/iosMain/kotlin/software/amazon/app/platform/recipes/swiftui/IosNoOpSwiftUiHomeRenderer.kt new file mode 100644 index 00000000..fdc6791b --- /dev/null +++ b/recipes/common/impl/src/iosMain/kotlin/software/amazon/app/platform/recipes/swiftui/IosNoOpSwiftUiHomeRenderer.kt @@ -0,0 +1,22 @@ +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import software.amazon.app.platform.inject.ContributesRenderer +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model +import software.amazon.app.platform.renderer.ComposeRenderer + +/** This is a no-op renderer that exists so the RendererFactory doesn't freak out. */ +@ContributesRenderer +class IosNoOpSwiftUiHomeRenderer : ComposeRenderer() { + @Composable + override fun Compose(model: Model) { + Box(modifier = Modifier.fillMaxSize()) { + Text("Rendering the native UI on top", Modifier.align(Alignment.Center)) + } + } +} diff --git a/recipes/common/impl/src/noIosMain/kotlin/software/amazon/app/platform/recipes/swiftui/CommonSwiftUiHomeRenderer.kt b/recipes/common/impl/src/noIosMain/kotlin/software/amazon/app/platform/recipes/swiftui/CommonSwiftUiHomeRenderer.kt new file mode 100644 index 00000000..4710b514 --- /dev/null +++ b/recipes/common/impl/src/noIosMain/kotlin/software/amazon/app/platform/recipes/swiftui/CommonSwiftUiHomeRenderer.kt @@ -0,0 +1,26 @@ +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import software.amazon.app.platform.inject.ContributesRenderer +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model +import software.amazon.app.platform.renderer.ComposeRenderer + +/** + * SwiftUI integration isn't supported for platforms other than iOS. There are two methods of + * rendering this model. One `PresenterView` implemented in Swift and one in the special `noIos` + * source folder. At runtime depending on the platform the right method is used. + */ +@ContributesRenderer +class CommonSwiftUiHomeRenderer : ComposeRenderer() { + @Composable + override fun Compose(model: Model) { + Box(modifier = Modifier.fillMaxSize()) { + Text("SwiftUI is only supported on iOS", Modifier.align(Alignment.Center)) + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift b/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift index 5eb518ff..5941a1c7 100644 --- a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift +++ b/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift @@ -14,12 +14,40 @@ struct ComposeView: UIViewControllerRepresentable { init(rootScopeProvider: RootScopeProvider) { self.rootScopeProvider = rootScopeProvider } + + func makeCoordinator() -> Coordinator { + Coordinator() + } func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) + let composeVC = MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) { model in + context.coordinator.navigateToNativeViewController(model: model) + } + + // Wrap in navigation controller + let navController = UINavigationController(rootViewController: composeVC) + + context.coordinator.navigationController = navController + + return navController } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + class Coordinator { + weak var navigationController: UINavigationController? + + func navigateToNativeViewController(model: BaseModel) { + let modelHash = ObjectIdentifier(model as AnyObject).hashValue + + DispatchQueue.main.async { [weak self] in + guard let navController = self?.navigationController else { return } + + let detailVC = CounterViewController() + navController.pushViewController(detailVC, animated: true) + } + } + } } struct ComposeContentView: View { diff --git a/recipes/recipesIosApp/recipesIosApp/Counter/CounterChildPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/Counter/CounterChildPresenterView.swift new file mode 100644 index 00000000..433bd485 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/Counter/CounterChildPresenterView.swift @@ -0,0 +1,7 @@ +// +// CounterChildPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + diff --git a/recipes/recipesIosApp/recipesIosApp/Counter/CounterRootPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/Counter/CounterRootPresenterView.swift new file mode 100644 index 00000000..edccf82f --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/Counter/CounterRootPresenterView.swift @@ -0,0 +1,22 @@ +// +// CounterRootPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import SwiftUI +import RecipesApp + +struct CounterRootPresenterView: View { + + public var body: some View { + VStack { + Text("I am a SwiftUI view") + .font(.system(size: 36)) + + Text("🎉") + .font(.system(size: 72)) + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/Counter/CounterViewController.swift b/recipes/recipesIosApp/recipesIosApp/Counter/CounterViewController.swift new file mode 100644 index 00000000..67cd1455 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/Counter/CounterViewController.swift @@ -0,0 +1,41 @@ +// +// CounterViewController.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import UIKit +import SwiftUI + +class CounterViewController: UIViewController { + public init() { + super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let swiftUIView = CounterRootPresenterView() + + let hostingController = UIHostingController(rootView: swiftUIView) + + addChild(hostingController) + view.addSubview(hostingController.view) + + hostingController.view.frame = view.bounds + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + hostingController.didMove(toParent: self) + } +}