Skip to content

Commit e7dd43d

Browse files
committed
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
1 parent e2fd040 commit e7dd43d

File tree

16 files changed

+569
-40
lines changed

16 files changed

+569
-40
lines changed

recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt

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

33
import me.tatarka.inject.annotations.IntoSet
44
import me.tatarka.inject.annotations.Provides
5+
import software.amazon.app.platform.presenter.molecule.MoleculeScopeFactory
6+
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter
57
import software.amazon.app.platform.scope.Scoped
68
import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped
79
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@@ -28,4 +30,10 @@ interface AppComponent {
2830
* needed.
2931
*/
3032
@Provides @IntoSet @ForScope(AppScope::class) fun provideEmptyScoped(): Scoped = Scoped.NO_OP
33+
34+
/** The root presenter for the SwiftUI recipe. */
35+
val swiftUiHomePresenter: SwiftUiHomePresenter
36+
37+
/** Factory needed to launch presenters from native. */
38+
val moleculeScopeFactory: MoleculeScopeFactory
3139
}

recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import software.amazon.app.platform.scope.RootScopeProvider
44
import software.amazon.app.platform.scope.Scope
55
import software.amazon.app.platform.scope.coroutine.addCoroutineScopeScoped
66
import software.amazon.app.platform.scope.di.addKotlinInjectComponent
7+
import software.amazon.app.platform.scope.di.kotlinInjectComponent
78
import software.amazon.app.platform.scope.register
89

910
/**
@@ -17,6 +18,10 @@ class DemoApplication : RootScopeProvider {
1718
override val rootScope: Scope
1819
get() = checkNotNull(_rootScope) { "Must call create() first." }
1920

21+
/** Provides the application scope DI component. */
22+
val appComponent: AppComponent
23+
get() = rootScope.kotlinInjectComponent<AppComponent>()
24+
2025
/** Creates the root scope and remembers the instance. */
2126
fun create(appComponent: AppComponent) {
2227
check(_rootScope == null) { "create() should be called only once." }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass")
2+
3+
package software.amazon.app.platform.recipes.swiftui
4+
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.produceState
8+
import androidx.compose.runtime.snapshots.SnapshotStateList
9+
import kotlin.time.Duration.Companion.seconds
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.isActive
12+
import software.amazon.app.platform.presenter.BaseModel
13+
import software.amazon.app.platform.presenter.molecule.MoleculePresenter
14+
import software.amazon.app.platform.recipes.swiftui.SwiftUiChildPresenter.Model
15+
16+
class SwiftUiChildPresenter(
17+
private val index: Int,
18+
private val backstack: SnapshotStateList<MoleculePresenter<Unit, out BaseModel>>,
19+
) : MoleculePresenter<Unit, Model> {
20+
@Composable
21+
override fun present(input: Unit): Model {
22+
val counter by
23+
produceState(0) {
24+
while (isActive) {
25+
delay(1.seconds)
26+
value += 1
27+
}
28+
}
29+
30+
return Model(index = index, counter = counter) {
31+
when (it) {
32+
Event.AddPeer ->
33+
backstack.add(SwiftUiChildPresenter(index = index + 1, backstack = backstack))
34+
}
35+
}
36+
}
37+
38+
data class Model(val index: Int, val counter: Int, val onEvent: (Event) -> Unit) : BaseModel
39+
40+
sealed interface Event {
41+
data object AddPeer : Event
42+
}
43+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass")
2+
3+
package software.amazon.app.platform.recipes.swiftui
4+
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.mutableStateListOf
7+
import androidx.compose.runtime.remember
8+
import me.tatarka.inject.annotations.Inject
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.SwiftUiHomePresenter.Model
12+
13+
/**
14+
* A presenter that manages a backstack of presenters that are rendered by SwiftUI's
15+
* `NavigationStack`. All presenters in this backstack are always active, because `NavigationStack`
16+
* renders them on stack modification. In SwiftUI this is necessary as views remain alive even when
17+
* they are no longer visible.
18+
*
19+
* A detail of note for this class is that we pass a list of [BaseModel] to the view but receive a
20+
* list of [Int] back where each integer represents the position of a presenter in the backstack
21+
* list. This is because to share control of state with `NavigationStack` we need to initialize the
22+
* `NavigationStack` with a `Binding` to a collection of `Hashable` data values. [BaseModel] by
23+
* default is not `Hashable` and we cannot extend it to conform to `Hashable` due to current
24+
* Kotlin-Swift interop limitations. As such in Swift the list of [BaseModel] is converted to a list
25+
* of indices, which are hashable by default. This should be sufficient to handle most navigation
26+
* cases but if it is required to receive more information to determine how to modify the presenter
27+
* backstack, it is possible to create a generic class that implements [BaseModel] and wrap that
28+
* class in a hashable `struct`.
29+
*/
30+
@Inject
31+
class SwiftUiHomePresenter : MoleculePresenter<Unit, Model> {
32+
@Composable
33+
override fun present(input: Unit): Model {
34+
val backstack = remember {
35+
mutableStateListOf<MoleculePresenter<Unit, out BaseModel>>().apply {
36+
// There must be always one element.
37+
add(SwiftUiChildPresenter(index = 0, backstack = this))
38+
}
39+
}
40+
41+
return Model(modelBackstack = backstack.map { it.present(Unit) }) {
42+
when (it) {
43+
is Event.BackstackModificationEvent -> {
44+
val updatedBackstack = it.indicesBackstack.map { index -> backstack[index] }
45+
46+
backstack.clear()
47+
backstack.addAll(updatedBackstack)
48+
}
49+
}
50+
}
51+
}
52+
53+
/**
54+
* Model that contains all the information needed for SwiftUI to render the backstack.
55+
* [modelBackstack] contains the backage and [onEvent] exposes an event handling function that can
56+
* be called by the binding that `NavigationStack` is initialized with.
57+
*/
58+
data class Model(val modelBackstack: List<BaseModel>, val onEvent: (Event) -> Unit) : BaseModel
59+
60+
/** All events that [SwiftUiHomePresenter] can process. */
61+
sealed interface Event {
62+
/** Sent when `NavigationStack` has modified its stack. */
63+
data class BackstackModificationEvent(val indicesBackstack: List<Int>) : Event
64+
}
65+
}

recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// ContentView.swift
3+
// recipesIosApp
4+
//
5+
// Created by Wang, Jessalyn on 11/10/25.
6+
//
7+
8+
import SwiftUI
9+
import RecipesApp
10+
11+
struct ComposeView: UIViewControllerRepresentable {
12+
private var rootScopeProvider: RootScopeProvider
13+
14+
init(rootScopeProvider: RootScopeProvider) {
15+
self.rootScopeProvider = rootScopeProvider
16+
}
17+
18+
func makeUIViewController(context: Context) -> UIViewController {
19+
MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider)
20+
}
21+
22+
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
23+
}
24+
25+
struct ContentView: View {
26+
var appDelegate: AppDelegate
27+
28+
@State var showComposeRecipes = false
29+
@State var showSwiftUIRecipe = false
30+
31+
init(appDelegate: AppDelegate) {
32+
self.appDelegate = appDelegate
33+
}
34+
35+
var body: some View {
36+
VStack {
37+
Spacer()
38+
39+
Button(action: { showComposeRecipes.toggle() }) {
40+
Text("CMP-rendered recipes")
41+
}
42+
.buttonStyle(.borderedProminent)
43+
44+
Spacer()
45+
46+
Button(action: { showSwiftUIRecipe.toggle() }) {
47+
Text("SwiftUI recipe")
48+
}
49+
.buttonStyle(.borderedProminent)
50+
51+
Spacer()
52+
}
53+
.sheet(isPresented: $showComposeRecipes) {
54+
ComposeView(rootScopeProvider: appDelegate)
55+
.ignoresSafeArea(.keyboard) // Compose has its own keyboard handler
56+
}
57+
.sheet(isPresented: $showSwiftUIRecipe) {
58+
SwiftUiRootPresenterView(
59+
homePresenter: SwiftUiHomePresenterBuilder(appDelegate: appDelegate).makeHomePresenter()
60+
)
61+
}
62+
}
63+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// AppPlatform+Extensions.swift
3+
// recipesIosApp
4+
//
5+
// Created by Wang, Jessalyn on 11/24/25.
6+
//
7+
8+
import RecipesApp
9+
10+
extension Presenter {
11+
/// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`.
12+
func viewModels<Model>(ofType type: Model.Type) -> AsyncThrowingStream<Model, Error> {
13+
model
14+
.values()
15+
.compactMap { $0 as? Model }
16+
.asAsyncThrowingStream()
17+
}
18+
19+
}
20+
21+
enum KotlinFlowError {
22+
case unexpectedValueInKotlinFlow(value: Any, expectedType: String)
23+
}
24+
25+
extension Kotlinx_coroutines_coreFlow {
26+
27+
/// Returns an async sequence of Any? from the Kotlin Flow.
28+
///
29+
/// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check.
30+
/// You can use `valuesOfType` instead which returns a stream that throws an error if the values are not of the right type.
31+
/// `valuesOfType` is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent.
32+
func values() -> AsyncThrowingStream<Any?, Error> {
33+
let collector = Kotlinx_coroutines_coreFlowCollectorImpl<Any?>()
34+
collect(collector: collector, completionHandler: collector.onComplete(_:))
35+
return collector.values
36+
}
37+
38+
/// Returns an async sequence from the Kotlin Flow.
39+
///
40+
/// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check.
41+
/// If the Flow sends the right type, this stream will throw an error.
42+
/// This is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent.
43+
func valuesOfType<T>(_ type: T.Type = T.self) -> AsyncThrowingStream<T, Error> {
44+
let collector = Kotlinx_coroutines_coreFlowCollectorImpl<T>()
45+
Task { @MainActor in
46+
do {
47+
try await collect(collector: collector)
48+
collector.onComplete(nil)
49+
} catch {
50+
collector.onComplete(error)
51+
}
52+
}
53+
return collector.values
54+
}
55+
56+
}
57+
58+
fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl<Value>: Kotlinx_coroutines_coreFlowCollector {
59+
60+
let values: AsyncThrowingStream<Value, Error>
61+
private let continuation: AsyncThrowingStream<Value, Error>.Continuation
62+
63+
init() {
64+
let (values, continuation) = AsyncThrowingStream<Value, Error>.makeStream()
65+
self.values = values
66+
self.continuation = continuation
67+
}
68+
69+
func emit(value: Any?) async throws {
70+
if let castedValue = value as? Value {
71+
continuation.yield(castedValue)
72+
}
73+
}
74+
75+
func onComplete(_ error: Error?) {
76+
continuation.finish(throwing: error)
77+
}
78+
79+
deinit {
80+
print("Deiniting collector")
81+
}
82+
}
83+
84+
extension AsyncSequence {
85+
86+
func asAsyncThrowingStream() -> AsyncThrowingStream<Element, Error> {
87+
if let self = self as? AsyncThrowingStream<Element, Error> {
88+
return self
89+
}
90+
var asyncIterator = self.makeAsyncIterator()
91+
return AsyncThrowingStream<Element, Error> {
92+
try await asyncIterator.next()
93+
}
94+
}
95+
96+
}
97+
98+
extension Int {
99+
/// Converts Swift Int to Kotlin's Int type for interop.
100+
func toKotlinInt() -> KotlinInt {
101+
return KotlinInt(integerLiteral: self)
102+
}
103+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// MoleculePresenterWrapper.swift
3+
// recipesIosApp
4+
//
5+
// Created by Wang, Jessalyn on 11/24/25.
6+
//
7+
8+
import RecipesApp
9+
10+
/// Wraps a Molecule Presenter that has been converted into a regular Presenter.
11+
///
12+
/// In order to convert a Molecule Presenter to a regular Presenter, we need to create a MoleculeScope,
13+
/// and that scope needs to be cancelled when we are done,
14+
/// so we create this class which will automatically cancel the scope upon deinit.
15+
class MoleculePresenterWrapper: Presenter {
16+
public var model: Kotlinx_coroutines_coreStateFlow { wrapped.model }
17+
18+
private let wrapped: Presenter
19+
private let scope: MoleculeScope
20+
21+
public init(moleculeScopeFactory: MoleculeScopeFactory, moleculePresenter: MoleculePresenter, input: Any) {
22+
let scope = moleculeScopeFactory.createMoleculeScope()
23+
self.scope = scope
24+
self.wrapped = scope.launchMoleculePresenter(presenter: moleculePresenter, input: input)
25+
}
26+
27+
deinit {
28+
scope.cancel()
29+
}
30+
31+
}

0 commit comments

Comments
 (0)