Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions recipes/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
}
15 changes: 14 additions & 1 deletion recipes/common/impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -17,6 +19,8 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {
override fun present(input: Unit): Model {
val backstack = checkNotNull(LocalBackstackScope.current)

Logger.i { "Hello World" }

return Model {
when (it) {
Event.AddPresenterToBackstack -> {
Expand All @@ -30,6 +34,10 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {
Event.Navigation3 -> {
backstack.push(Navigation3HomePresenter())
}

Event.SwiftUI -> {
backstack.push(SwiftUiHomePresenter())
}
}
}
}
Expand All @@ -50,5 +58,8 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {

/** Show the presenter highlighting navigation3 integration. */
data object Navigation3 : Event

/** Show the presenter highlighting SwiftUI integration. */
data object SwiftUI : Event
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ class LandingRenderer : ComposeRenderer<Model>() {
) {
Text("Navigation3")
}
Button(
onClick = { model.onEvent(LandingPresenter.Event.SwiftUI) },
modifier = Modifier.padding(top = 12.dp),
) {
Text("SwiftUI")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit, out BaseModel>>,
) : MoleculePresenter<Unit, Model> {
@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
}
}
Original file line number Diff line number Diff line change
@@ -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<Unit, Model> {
@Composable
override fun present(input: Unit): Model {
val backstack = remember {
mutableStateListOf<MoleculePresenter<Unit, out BaseModel>>().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<BaseModel>,
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<Int>
) : Event
}
}
Original file line number Diff line number Diff line change
@@ -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<Model>() {
@Composable
override fun Compose(model: Model) {
Box(modifier = Modifier.fillMaxSize()) {
Text("Rendering the native UI on top", Modifier.align(Alignment.Center))
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Model>() {
@Composable
override fun Compose(model: Model) {
Box(modifier = Modifier.fillMaxSize()) {
Text("SwiftUI is only supported on iOS", Modifier.align(Alignment.Center))
}
}
}
30 changes: 29 additions & 1 deletion recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//
// CounterChildPresenterView.swift
// recipesIosApp
//
// Created by Wang, Jessalyn on 11/23/25.
//

Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading
Loading