Skip to content

Commit 2068bd5

Browse files
committed
DNM: Launch a SwiftUI view from LandingPresenter
1 parent e2fd040 commit 2068bd5

File tree

13 files changed

+313
-5
lines changed

13 files changed

+313
-5
lines changed

recipes/app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ appPlatform {
2121
dependencies {
2222
commonMainImplementation project(':recipes:common:impl')
2323

24+
//noinspection UseTomlInstead
25+
commonMainImplementation("co.touchlab:kermit:2.0.4")
26+
2427
androidMainImplementation libs.androidx.activity.compose
2528
}

recipes/app/src/iosMain/kotlin/software/amazon/app/platform/recipes/MainViewController.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import androidx.compose.runtime.collectAsState
55
import androidx.compose.runtime.getValue
66
import androidx.compose.runtime.remember
77
import androidx.compose.ui.window.ComposeUIViewController
8+
import co.touchlab.kermit.Logger
89
import platform.UIKit.UIViewController
10+
import software.amazon.app.platform.presenter.BaseModel
11+
import software.amazon.app.platform.recipes.backstack.CrossSlideBackstackPresenter
12+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter
13+
import software.amazon.app.platform.recipes.template.RecipesAppTemplate
914
import software.amazon.app.platform.renderer.ComposeRendererFactory
1015
import software.amazon.app.platform.renderer.Renderer
1116
import software.amazon.app.platform.scope.RootScopeProvider
@@ -19,7 +24,7 @@ import software.amazon.app.platform.scope.di.kotlinInjectComponent
1924
* good enough for the iOS recipes app.
2025
*/
2126
@Suppress("unused")
22-
fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
27+
fun mainViewController(rootScopeProvider: RootScopeProvider, renderSwiftUi: (BaseModel) -> Unit): UIViewController =
2328
ComposeUIViewController {
2429
// Create a single instance.
2530
val templateProvider = remember {
@@ -42,6 +47,16 @@ fun mainViewController(rootScopeProvider: RootScopeProvider): UIViewController =
4247
// Render templates using our Renderer runtime.
4348
val template by templateProvider.templates.collectAsState()
4449

45-
val renderer = factory.getRenderer(template::class)
46-
renderer.renderCompose(template)
50+
// TODO: do something about this.....
51+
val swiftUiModel =
52+
((template as? RecipesAppTemplate.FullScreenTemplate)?.model
53+
as? CrossSlideBackstackPresenter.Model)?.delegate as? SwiftUiHomePresenter.Model
54+
55+
if (swiftUiModel == null) {
56+
val renderer = factory.getRenderer(template::class)
57+
58+
renderer.renderCompose(template)
59+
} else {
60+
renderSwiftUi(swiftUiModel)
61+
}
4762
}

recipes/common/impl/build.gradle

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,25 @@ appPlatform {
1616

1717
kotlin {
1818
def noAndroid = sourceSets.create("noAndroidMain")
19+
def noIos = sourceSets.create("noIosMain")
1920

2021
sourceSets.named('commonMain').configure {
2122
noAndroid.dependsOn(it)
23+
noIos.dependsOn(it)
2224
}
23-
sourceSets.named('appleAndDesktopMain').configure {
25+
sourceSets.named('appleMain').configure {
2426
it.dependsOn(noAndroid)
2527
}
2628
sourceSets.named('wasmJsMain').configure {
2729
it.dependsOn(noAndroid)
30+
it.dependsOn(noIos)
31+
}
32+
sourceSets.named('desktopMain').configure {
33+
it.dependsOn(noAndroid)
34+
it.dependsOn(noIos)
35+
}
36+
sourceSets.named('androidMain').configure {
37+
it.dependsOn(noIos)
2838
}
2939
}
3040

@@ -35,6 +45,9 @@ dependencies {
3545
commonMainImplementation compose.runtimeSaveable
3646
commonMainImplementation compose.ui
3747

48+
//noinspection UseTomlInstead
49+
commonMainImplementation("co.touchlab:kermit:2.0.4")
50+
3851
commonMainImplementation libs.androidx.collection
3952

4053
androidMainImplementation libs.navigation3.runtime

recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingPresenter.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package software.amazon.app.platform.recipes.landing
22

33
import androidx.compose.runtime.Composable
4+
import co.touchlab.kermit.Logger
45
import me.tatarka.inject.annotations.Inject
56
import software.amazon.app.platform.presenter.BaseModel
67
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
@@ -9,6 +10,7 @@ import software.amazon.app.platform.recipes.backstack.LocalBackstackScope
910
import software.amazon.app.platform.recipes.backstack.presenter.BackstackChildPresenter
1011
import software.amazon.app.platform.recipes.landing.LandingPresenter.Model
1112
import software.amazon.app.platform.recipes.nav3.Navigation3HomePresenter
13+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter
1214

1315
/** The presenter that is responsible to show the content of the landing page in the Recipes app. */
1416
@Inject
@@ -17,6 +19,8 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {
1719
override fun present(input: Unit): Model {
1820
val backstack = checkNotNull(LocalBackstackScope.current)
1921

22+
Logger.i { "Hello World" }
23+
2024
return Model {
2125
when (it) {
2226
Event.AddPresenterToBackstack -> {
@@ -30,6 +34,10 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {
3034
Event.Navigation3 -> {
3135
backstack.push(Navigation3HomePresenter())
3236
}
37+
38+
Event.SwiftUI -> {
39+
backstack.push(SwiftUiHomePresenter())
40+
}
3341
}
3442
}
3543
}
@@ -50,5 +58,8 @@ class LandingPresenter : MoleculePresenter<Unit, Model> {
5058

5159
/** Show the presenter highlighting navigation3 integration. */
5260
data object Navigation3 : Event
61+
62+
/** Show the presenter highlighting SwiftUI integration. */
63+
data object SwiftUI : Event
5364
}
5465
}

recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/landing/LandingRenderer.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ class LandingRenderer : ComposeRenderer<Model>() {
3737
) {
3838
Text("Navigation3")
3939
}
40+
Button(
41+
onClick = { model.onEvent(LandingPresenter.Event.SwiftUI) },
42+
modifier = Modifier.padding(top = 12.dp),
43+
) {
44+
Text("SwiftUI")
45+
}
4046
}
4147
}
4248
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package software.amazon.app.platform.recipes.swiftui
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.getValue
5+
import androidx.compose.runtime.produceState
6+
import androidx.compose.runtime.snapshots.SnapshotStateList
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.isActive
9+
import software.amazon.app.platform.presenter.BaseModel
10+
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
11+
import software.amazon.app.platform.recipes.swiftui.SwiftUiChildPresenter.Model
12+
import kotlin.time.Duration.Companion.seconds
13+
14+
class SwiftUiChildPresenter(
15+
private val index: Int,
16+
private val backstack: SnapshotStateList<MoleculePresenter<Unit, out BaseModel>>,
17+
) : MoleculePresenter<Unit, Model> {
18+
@Composable
19+
override fun present(input: Unit): Model {
20+
val counter by
21+
produceState(0) {
22+
while (isActive) {
23+
delay(1.seconds)
24+
value += 1
25+
}
26+
}
27+
28+
return Model( index = index, counter = counter) {
29+
when (it) {
30+
Event.AddPeer ->
31+
backstack.add(SwiftUiChildPresenter(index = index + 1, backstack = backstack))
32+
}
33+
}
34+
}
35+
36+
data class Model(
37+
val index: Int,
38+
val counter: Int,
39+
val onEvent: (Event) -> Unit,
40+
) : BaseModel
41+
42+
sealed interface Event {
43+
data object AddPeer : Event
44+
}
45+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package software.amazon.app.platform.recipes.swiftui
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.mutableStateListOf
5+
import androidx.compose.runtime.remember
6+
import me.tatarka.inject.annotations.Inject
7+
import software.amazon.app.platform.presenter.BaseModel
8+
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
9+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model
10+
11+
/**
12+
* A presenter that manages a backstack of presenters that are rendered by SwiftUI's
13+
* `NavigationStack`. All presenters in this backstack are always active, because `NavigationStack`
14+
* renders them on stack modification. In SwiftUI this is necessary as views remain alive even when
15+
* they are no longer visible.
16+
*
17+
* A detail of note for this class is that we pass a list of [BaseModel] to the view but
18+
* receive a list of [Int] back where each integer represents the position of a presenter in the
19+
* backstack list. This is because to share control of state with `NavigationStack` we need to
20+
* initialize the `NavigationStack` with a `Binding` to a collection of `Hashable` data values.
21+
* [BaseModel] by default is not `Hashable` and we cannot extend it to conform to `Hashable` due to
22+
* current Kotlin-Swift interop limitations. As such in Swift the list of [BaseModel] is converted
23+
* to a list of indices, which are hashable by default. This should be sufficient to handle most
24+
* navigation cases but if it is required to receive more information to determine how to modify the
25+
* presenter backstack, it is possible to create a generic class that implements [BaseModel] and
26+
* wrap that class in a hashable `struct`.
27+
*/
28+
@Inject
29+
class SwiftUiHomePresenter : MoleculePresenter<Unit, Model> {
30+
@Composable
31+
override fun present(input: Unit): Model {
32+
val backstack = remember {
33+
mutableStateListOf<MoleculePresenter<Unit, out BaseModel>>().apply {
34+
// There must be always one element.
35+
add(SwiftUiChildPresenter(index = 0, backstack = this))
36+
}
37+
}
38+
39+
return Model(modelBackstack = backstack.map { it.present(Unit) }) {
40+
when (it) {
41+
is Event.BackstackModificationEvent -> {
42+
val updatedBackstack = it.indicesBackstack.map { index -> backstack[index] }
43+
44+
backstack.clear()
45+
backstack.addAll(updatedBackstack)
46+
}
47+
}
48+
}
49+
}
50+
51+
/**
52+
* Model that contains all the information needed for SwiftUI to render the backstack.
53+
* [modelBackstack] contains the backage and [onEvent] exposes an event handling function that can
54+
* be called by the binding that `NavigationStack` is initialized with.
55+
*/
56+
data class Model(
57+
val modelBackstack: List<BaseModel>,
58+
val onEvent: (Event) -> Unit
59+
) : BaseModel
60+
61+
/** All events that [SwiftUiHomePresenter] can process. */
62+
sealed interface Event {
63+
/** Sent when `NavigationStack` has modified its stack. */
64+
data class BackstackModificationEvent(
65+
val indicesBackstack: List<Int>
66+
) : Event
67+
}
68+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package software.amazon.app.platform.recipes.swiftui
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material3.Text
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.ui.Alignment
8+
import androidx.compose.ui.Modifier
9+
import software.amazon.app.platform.inject.ContributesRenderer
10+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model
11+
import software.amazon.app.platform.renderer.ComposeRenderer
12+
13+
/** This is a no-op renderer that exists so the RendererFactory doesn't freak out. */
14+
@ContributesRenderer
15+
class IosNoOpSwiftUiHomeRenderer : ComposeRenderer<Model>() {
16+
@Composable
17+
override fun Compose(model: Model) {
18+
Box(modifier = Modifier.fillMaxSize()) {
19+
Text("Rendering the native UI on top", Modifier.align(Alignment.Center))
20+
}
21+
}
22+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package software.amazon.app.platform.recipes.swiftui
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material3.Text
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.ui.Alignment
8+
import androidx.compose.ui.Modifier
9+
import software.amazon.app.platform.inject.ContributesRenderer
10+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model
11+
import software.amazon.app.platform.renderer.ComposeRenderer
12+
13+
/**
14+
* SwiftUI integration isn't supported for platforms other than iOS. There are two methods of
15+
* rendering this model. One `PresenterView` implemented in Swift and one in the special `noIos`
16+
* source folder. At runtime depending on the platform the right method is used.
17+
*/
18+
@ContributesRenderer
19+
class CommonSwiftUiHomeRenderer : ComposeRenderer<Model>() {
20+
@Composable
21+
override fun Compose(model: Model) {
22+
Box(modifier = Modifier.fillMaxSize()) {
23+
Text("SwiftUI is only supported on iOS", Modifier.align(Alignment.Center))
24+
}
25+
}
26+
}

recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,40 @@ struct ComposeView: UIViewControllerRepresentable {
1414
init(rootScopeProvider: RootScopeProvider) {
1515
self.rootScopeProvider = rootScopeProvider
1616
}
17+
18+
func makeCoordinator() -> Coordinator {
19+
Coordinator()
20+
}
1721

1822
func makeUIViewController(context: Context) -> UIViewController {
19-
MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider)
23+
let composeVC = MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) { model in
24+
context.coordinator.navigateToNativeViewController(model: model)
25+
}
26+
27+
// Wrap in navigation controller
28+
let navController = UINavigationController(rootViewController: composeVC)
29+
30+
context.coordinator.navigationController = navController
31+
32+
return navController
2033
}
2134

2235
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
36+
37+
class Coordinator {
38+
weak var navigationController: UINavigationController?
39+
40+
func navigateToNativeViewController(model: BaseModel) {
41+
let modelHash = ObjectIdentifier(model as AnyObject).hashValue
42+
43+
DispatchQueue.main.async { [weak self] in
44+
guard let navController = self?.navigationController else { return }
45+
46+
let detailVC = CounterViewController()
47+
navController.pushViewController(detailVC, animated: true)
48+
}
49+
}
50+
}
2351
}
2452

2553
struct ComposeContentView: View {

0 commit comments

Comments
 (0)