Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package software.amazon.app.platform.recipes

import me.tatarka.inject.annotations.IntoSet
import me.tatarka.inject.annotations.Provides
import software.amazon.app.platform.presenter.molecule.MoleculeScopeFactory
import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter
import software.amazon.app.platform.scope.Scoped
import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
Expand All @@ -28,4 +30,10 @@ interface AppComponent {
* needed.
*/
@Provides @IntoSet @ForScope(AppScope::class) fun provideEmptyScoped(): Scoped = Scoped.NO_OP

/** The root presenter for the SwiftUI recipe. */
val swiftUiHomePresenter: SwiftUiHomePresenter

/** Factory needed to launch presenters from native. */
val moleculeScopeFactory: MoleculeScopeFactory
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import software.amazon.app.platform.scope.RootScopeProvider
import software.amazon.app.platform.scope.Scope
import software.amazon.app.platform.scope.coroutine.addCoroutineScopeScoped
import software.amazon.app.platform.scope.di.addKotlinInjectComponent
import software.amazon.app.platform.scope.di.kotlinInjectComponent
import software.amazon.app.platform.scope.register

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

/** Provides the application scope DI component. */
val appComponent: AppComponent
get() = rootScope.kotlinInjectComponent<AppComponent>()

/** Creates the root scope and remembers the instance. */
fun create(appComponent: AppComponent) {
check(_rootScope == null) { "create() should be called only once." }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass")

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 kotlin.time.Duration.Companion.seconds
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

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,65 @@
@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass")

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
}
}
35 changes: 0 additions & 35 deletions recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift

This file was deleted.

63 changes: 63 additions & 0 deletions recipes/recipesIosApp/recipesIosApp/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// ContentView.swift
// recipesIosApp
//
// Created by Wang, Jessalyn on 11/10/25.
//

import SwiftUI
import RecipesApp

struct ComposeView: UIViewControllerRepresentable {
private var rootScopeProvider: RootScopeProvider

init(rootScopeProvider: RootScopeProvider) {
self.rootScopeProvider = rootScopeProvider
}

func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider)
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

struct ContentView: View {
var appDelegate: AppDelegate

@State var showComposeRecipes = false
@State var showSwiftUIRecipe = false

init(appDelegate: AppDelegate) {
self.appDelegate = appDelegate
}

var body: some View {
VStack {
Spacer()

Button(action: { showComposeRecipes.toggle() }) {
Text("CMP-rendered recipes")
}
.buttonStyle(.borderedProminent)

Spacer()

Button(action: { showSwiftUIRecipe.toggle() }) {
Text("SwiftUI recipe")
}
.buttonStyle(.borderedProminent)

Spacer()
}
.sheet(isPresented: $showComposeRecipes) {
ComposeView(rootScopeProvider: appDelegate)
.ignoresSafeArea(.keyboard) // Compose has its own keyboard handler
}
.sheet(isPresented: $showSwiftUIRecipe) {
SwiftUiRootPresenterView(
homePresenter: SwiftUiHomePresenterBuilder(appDelegate: appDelegate).makeHomePresenter()
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// AppPlatform+Extensions.swift
// recipesIosApp
//
// Created by Wang, Jessalyn on 11/24/25.
//

import RecipesApp
import SwiftUI

extension Presenter {
/// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`.
func viewModels<Model>(ofType type: Model.Type) -> AsyncThrowingStream<Model, Error> {
model
.values()
.compactMap { $0 as? Model }
.asAsyncThrowingStream()
}
}

enum KotlinFlowError {
case unexpectedValueInKotlinFlow(value: Any, expectedType: String)
}

extension Kotlinx_coroutines_coreFlow {

/// Returns an async sequence of Any? from the Kotlin Flow.
///
/// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check.
/// You can use `valuesOfType` instead which returns a stream that throws an error if the values are not of the right type.
/// `valuesOfType` is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent.
func values() -> AsyncThrowingStream<Any?, Error> {
let collector = Kotlinx_coroutines_coreFlowCollectorImpl<Any?>()
collect(collector: collector, completionHandler: collector.onComplete(_:))
return collector.values
}

/// Returns an async sequence from the Kotlin Flow.
///
/// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check.
/// If the Flow sends the right type, this stream will throw an error.
/// This is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent.
func valuesOfType<T>(_ type: T.Type = T.self) -> AsyncThrowingStream<T, Error> {
let collector = Kotlinx_coroutines_coreFlowCollectorImpl<T>()
Task { @MainActor in
do {
try await collect(collector: collector)
collector.onComplete(nil)
} catch {
collector.onComplete(error)
}
}
return collector.values
}
}

fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl<Value>: Kotlinx_coroutines_coreFlowCollector {

let values: AsyncThrowingStream<Value, Error>
private let continuation: AsyncThrowingStream<Value, Error>.Continuation

init() {
let (values, continuation) = AsyncThrowingStream<Value, Error>.makeStream()
self.values = values
self.continuation = continuation
}

func emit(value: Any?) async throws {
if let castedValue = value as? Value {
continuation.yield(castedValue)
}
}

func onComplete(_ error: Error?) {
continuation.finish(throwing: error)
}

deinit {
print("Deiniting collector")
}
}

extension AsyncSequence {

func asAsyncThrowingStream() -> AsyncThrowingStream<Element, Error> {
if let self = self as? AsyncThrowingStream<Element, Error> {
return self
}
var asyncIterator = self.makeAsyncIterator()
return AsyncThrowingStream<Element, Error> {
try await asyncIterator.next()
}
}
}

extension Int {
/// Converts Swift Int to Kotlin's Int type for interop.
func toKotlinInt() -> KotlinInt {
return KotlinInt(integerLiteral: self)
}
}

extension BaseModel {
/// Gets the view for some `BaseModel`
///
/// Returns. created by `makeViewRenderer()` if a model conforms to `PresenterViewModel` otherwise, crash the build for
/// debug builds or return a default view.
@MainActor func getViewRenderer() -> AnyView {
guard let viewModel = self as? (any PresenterViewModel) else {
assertionFailure("ViewModel \(self) does not conform to `PresenterViewModel`")

// This is an implementation detail. If crashing is preferred even in production builds, `fatalError(..)`
// can be used instead
return AnyView(Text("Error, some ViewModel was not implemented!"))
}

return AnyView(viewModel.makeViewRenderer())
}
}
Loading
Loading