Skip to content

Commit 4d70897

Browse files
authored
Add a recipe for presenter integration with SwiftUI "renderers" (amzn#157)
* 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 amzn#154
1 parent e2fd040 commit 4d70897

File tree

15 files changed

+554
-40
lines changed

15 files changed

+554
-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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//
2+
// AppPlatform+Extensions.swift
3+
// recipesIosApp
4+
//
5+
// Created by Wang, Jessalyn on 11/24/25.
6+
//
7+
8+
import RecipesApp
9+
import SwiftUI
10+
11+
extension Presenter {
12+
/// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`.
13+
func viewModels<Model>(ofType type: Model.Type) -> AsyncThrowingStream<Model, Error> {
14+
model
15+
.values()
16+
.compactMap { $0 as? Model }
17+
.asAsyncThrowingStream()
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+
fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl<Value>: Kotlinx_coroutines_coreFlowCollector {
58+
59+
let values: AsyncThrowingStream<Value, Error>
60+
private let continuation: AsyncThrowingStream<Value, Error>.Continuation
61+
62+
init() {
63+
let (values, continuation) = AsyncThrowingStream<Value, Error>.makeStream()
64+
self.values = values
65+
self.continuation = continuation
66+
}
67+
68+
func emit(value: Any?) async throws {
69+
if let castedValue = value as? Value {
70+
continuation.yield(castedValue)
71+
}
72+
}
73+
74+
func onComplete(_ error: Error?) {
75+
continuation.finish(throwing: error)
76+
}
77+
78+
deinit {
79+
print("Deiniting collector")
80+
}
81+
}
82+
83+
extension AsyncSequence {
84+
85+
func asAsyncThrowingStream() -> AsyncThrowingStream<Element, Error> {
86+
if let self = self as? AsyncThrowingStream<Element, Error> {
87+
return self
88+
}
89+
var asyncIterator = self.makeAsyncIterator()
90+
return AsyncThrowingStream<Element, Error> {
91+
try await asyncIterator.next()
92+
}
93+
}
94+
}
95+
96+
extension Int {
97+
/// Converts Swift Int to Kotlin's Int type for interop.
98+
func toKotlinInt() -> KotlinInt {
99+
return KotlinInt(integerLiteral: self)
100+
}
101+
}
102+
103+
extension BaseModel {
104+
/// Gets the view for some `BaseModel`
105+
///
106+
/// Returns. created by `makeViewRenderer()` if a model conforms to `PresenterViewModel` otherwise, crash the build for
107+
/// debug builds or return a default view.
108+
@MainActor func getViewRenderer() -> AnyView {
109+
guard let viewModel = self as? (any PresenterViewModel) else {
110+
assertionFailure("ViewModel \(self) does not conform to `PresenterViewModel`")
111+
112+
// This is an implementation detail. If crashing is preferred even in production builds, `fatalError(..)`
113+
// can be used instead
114+
return AnyView(Text("Error, some ViewModel was not implemented!"))
115+
}
116+
117+
return AnyView(viewModel.makeViewRenderer())
118+
}
119+
}

0 commit comments

Comments
 (0)