Building the same app for multiple platforms is expensive. Cross-platform frameworks like Flutter, React Native, and Compose Multiplatform (CMP) promise big savings by letting you “write once, run anywhere,” and they’re fairly good at keeping that promise. However it comes with a big trade-off. They ship with their own rendering layer, which makes the UI often feel out of place on each platform.
Another overlooked cost of these frameworks is long-term developer happiness. Adopting them often means asking Android and/or iOS engineers to set aside years of hard-won expertise. That slows onboarding and can sap morale.
What if we could keep rendering 100 % native — Compose on Android, SwiftUI on iOS — while sharing everything else? That includes UI State computation, navigation, and business logic. This approach would achieve the cost advantages of multiplatform development without sacrificing the perfect user experience for each platform.
Why Circuit over Android’s ViewModel
Kotlin Multiplatform (KMP) excels at unifying domain and data-access logic, but when you try to integrate with the UI, an architectural rift appears. Jetpack Compose renders a tree of @Composable functions that recompose on state changes. In contrast, SwiftUI materializes a value-type View hierarchy observing an Observable reference type.
When working on ambiguous initiatives, the first impulse is to reach for familiar tooling to reduce risks as much as possible. Because Android engineers champion most KMP initiatives, the natural solution to share UI state is to use androidx.lifecycle.ViewModel since it can target multiple platforms.
ViewModel can indeed expose immutable view state (typically as a StateFlow) so that everything works on the Android side. However, it remains silent on who owns the coroutine scope, how that scope is canceled, and how navigation mutates application state once the code crosses the Swift boundary.
In addition, Android’s ViewModel brings a large payload of Android-specific baggage and semantics like viewModelScope and SavedStateHandle, as well as implicit ties to androidx.lifecycle.
These concepts have no native counterpart in SwiftUI.
Every discrepancy forces conditional compilation, façade classes, or duplicate persistence layers. It erodesthe benefits of a shared codebase.
Circuit eliminates this impedance mismatch. Its primitives — Presenter, Navigator, and Screen — carry no platform context, coroutine scope, or lifecycle baggage.
- On Android, the presenter runs inside Compose, leveraging compose native ability to recompose composables on state change
- On iOS, state is published as StateFlow via molecule that SwiftUI observes through a thin adapter.
- Navigation is handled by the same back-stack engine, merely wrapped so SwiftUI can inform it of pops.
The result is a single, testable source of truth for state, navigation, and deep-link resolution that both platforms treat as first class.
For organizations aiming to scale a shared codebase while preserving native ergonomics — Compose on Android, SwiftUI on iOS — Circuit is one of the best choices. It replaces ad-hoc wrappers with a coherent, platform-neutral execution model, letting teams focus on features instead of plumbing.
Observing KMP State from Swift UI with Circuit
Circuit relies on 2 main components.
- A Presenter solely responsible for the business logic of your UI as well as computing your app UI State.
- A UI. On Android it will be a composable. On iOS a SwiftUI view.
- A Screen which links the presenter and a UI together. In a nutshell, a Screen is a key to map a view to its presenter.
Creating a Circuit is therefore as simple as configuring how to create a Presenter from a Screen and how to instantiate a UI from a Screen.
val circuit = Circuit.Builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
.build()
In Compose, Circuit has built-in components to link everything together and is pretty straightforward on an Android device.
class MainActivity : Activity {
@Inject lateinit var circuit: Circuit
override fun onCreate(savedInstanceState: Bundle?) {
setContent {
CircuitCompositionLocals(circuit) {
CircuitContent(HomeScreen)
}
}
}
}
The goal of this post is to provide the same developer experience with SwiftUI.
Bridging CircuitPresenter
The presenter is the component responsible for emitting state. It relies on the Compose runtime, to trigger a recomposition whenever the state changes.
Swift doesn’t know anything about Compose. So our first step will be to bridge a Compose stream into a Kotlin Flow we can observe from Swift.Thanks to our friends from Cashapp, we can use Molecule that does exactly that.
class CircuitPresenterKotlinBridge<UiState : CircuitUiState>(
private val presenter: Presenter<UiState>,
scope: CoroutineScope,
) {
//iOS convenience
constructor(
presenter: Presenter<UiState>,
) : this(presenter, MainScope())
@OptIn(DelicateCircuitFoundationApi::class)
val state: StateFlow<UiState> = scope.launchMolecule(RecompositionMode.Immediate) {
val retainedStateRegistry = rememberRetainedStateRegistry() //(1)
withCompositionLocalProvider(
LocalRetainedStateRegistry provides retainedStateRegistry,
) {
presenter.present()
}
}
}
@OptIn(InternalComposeApi::class)
@Composable
private fun <R> withCompositionLocalProvider(
vararg values: ProvidedValue<*>,
content: @Composable () -> R,
): R {
currentComposer.startProviders(values)
return content().also { currentComposer.endProviders() }
}
(1): val retainedStateRegistry = rememberRetainedStateRegistry() allows us to support rememberRetained and collectAsRetainedState on iOS.
We now have a kotlin component that Swift can observe the State from thanks to SKIE from touchlab.
Our SwiftUI view can now observe the state from a presenter like below:
struct HomeView: View {
let presenter: HomePresenter
@State var state: HomeState = HomeState(...)
var body: some View {
VStack {
// SwiftUI views ..
}.task { @MainActor in
state = await presenter.present()
}
}
}
This has 2 major drawbacks:
- We have to inject the presenter in each View. This makes it harder to test the views because we can’t inject a state anymore. Ideally, we want the SwiftUI view to be a simple function of state.
- It forces the View to use a default state, duplicating the default state logic. Ideally, we’d like the presenter to be the sole owner of computing a state.
Let’s fix this by leveraging the composable nature of SwiftUI views and isolate the logic that observe the Circuit State in a dedicated CircuitView.
struct CircuitView: View {
@State private var state: (any CircuitUiState)?
private let presenter: CircuitPresenterKotlinBridge<any CircuitUiState>
private var content: (any CircuitUiState) -> AnyView
init(_ presenter: @autoclosure @escaping () -> CircuitPresenterKotlinBridge<any CircuitUiState>,
@ViewBuilder _ content: @escaping (any CircuitUiState) -> AnyView
) {
self.presenter = presenter()
self.content = content
}
public var body: some View {
ZStack {
if let state = self.state {
content(state)
}
}.task { @MainActor in
for await state in presenter.state {
self.state = state
}
}
}
}
The above view takes a presenter as an input and a child SwiftUIView that requires only a CircuitUiState as an input.
HomeView can now become:
struct HomeView: View {
let state: HomeState
var body: some View {
// SwiftUI views ..
}
}
This is exactly what we wanted. HomeView is a simple function of state and can be easily testable. We also don’t have to worry about providing a default state anymore.
The View tree now looks like the following:
CircuitView(presenter){ state in
HomeView(state)
}
We still have to provide a presenter to the CircuitView, though.
Providing the same circuit devx on SwiftUI than on Compose
If you remember, in Compose, rendering a UI is very simple and relies on 3 main components.
setContent {
CircuitCompositionLocals(circuit) { //(1) (2)
CircuitContent(HomeScreen) //(3)
}
}
- (1): Circuit object is responsible for creating UIs and Presenters based on a Screen
- (2): CircuitCompositionLocals is responsible for providing the circuit instance through the composition tree
- (3): CircuitContent is responsible to find the corresponding presenter and Ui to render using the circuit provided by CircuitCompositionLocals
Let’s implement those components with Swift UI.
Circuit
Circuit is the component that knows about how to create Presenters and UIs from a Screen.
It essentially exposes 2 main functions:
class Circuit {
fun presenter(
screen: Screen,
navigator: Navigator,
context: CircuitContext,
): Presenter<*>?
fun ui(
screen: Screen,
context: CircuitContext
): Ui<*>?
}
Circuit on KMP provides a PresenterFactory and a UIFactory. In a similar way, we need to expose a UIFactory for SwiftUI views.
final class Circuit: ObservableObject {
protocol UiFactory {
func create(screen: Screen) -> ((any CircuitUiState) -> AnyView)?
}
}
In Compose, we create our composable UI the following way.
class HomeUiFactory : Ui.Factory {
override fun create(screen: Screen, context: CircuitContext): Ui<*>? = when (screen) {
is HomeScreen -> ui<HomeState> { state, modifier ->
HomeUi(state, modifier)
}
else -> null
}
}
Let’s deliver the same devx in Swift. For that we need a ui helper function that allows to create a SwiftUIView given a state.
extension Circuit.UiFactory {
func ui<S: CircuitUiState>(@ViewBuilder _ viewBuilder: @escaping (S) -> some View) -> ((any CircuitUiState) -> AnyView) {
return { state -> AnyView(viewBuilder(state as! S)) }
}
}
We now have an easy way to create SwiftUI views from state. We can implement the rest of our Circuit swift class. Since presenters are pure business KMP logic, we can reuse the Presenter type as is.
All that is left is to implement the logic for Circuit to provide a SwiftUI view and a KMP Presenter given a screen.
final class Circuit: ObservableObject {
protocol UiFactory {
func create(screen: Screen) -> ((any CircuitUiState) -> AnyView)?
}
private let presenterFactories: [PresenterFactory] //(1)
private let uiFactories: [UiFactory]
init(
presenterFactories: [PresenterFactory],
uiFactories: [UiFactory]
) {
self.presenterFactories = presenterFactories
self.uiFactories = uiFactories
}
public func presenter(
screen: Screen,
navigator: Navigator,
) -> CircuitPresenterSwiftBridge<any CircuitUiState> {
var presenterCandidate: Presenter?
for factory in presenterFactories {
presenterCandidate = factory.create(
screen: screen,
navigator: navigator,
context: CircuitContext(parent: nil, tags: [:])
) //(2)
if presenterCandidate != nil {
break
}
}
let presenter = presenterCandidate ?? NoOpPresenter()
return CircuitPresenterKotlinBridge(presenter)
}
public func ui(screen: Screen) -> (any CircuitUiState) -> AnyView {
var uiCandidate: ((any CircuitUiState) -> AnyView)?
for factory in uiFactories {
uiCandidate = factory.create(screen: screen)
if uiCandidate != nil {
break
}
}
let ui = uiCandidate ?? { _ in
AnyView(Text(String(describing: type(of: screen))))
} //(3)
return { state in
return ui(state)
}
}
}
- (1): PresenterFactory is reused directly from the KMP Circuit library.
- (2): We look over the presenter factories to return a Presenter for a given screen.
- (3): It returns a default SwiftUIView if a UI hasn’t been found for a screen yet. This is very powerful to simply build a subset of the app and reduce build time significantly without the app crashing.
CircuitCompositionLocals
Since the word Composition doesn’t mean anything in SwiftUI, let’s name this component CircuitStack.
Its role is to make the circuit instance available through the SwiftUI view tree.
struct CircuitStack<Content>: View where Content: View {
private var content: () -> Content
private var circuit: Circuit
init(_ circuit: Circuit, @ViewBuilder _ content: @escaping () -> Content) {
self.content = content
self.circuit = circuit
}
var body: some View {
content()
.environmentObject(circuit) //(1)
}
}
- (1): environmentObject is the SwiftUI equivalent to CompositionLocalProvider in compose.
Any view in the subtree will be able to access the circuit instance using the below.
@EnvironmentObject var circuit: Circuit
CircuitContent
If you remember, our SwiftUI view tree currently looks like this.
CircuitView(presenter){ state in
HomeView(state)
}
Since, we’ve implemented 2 new components Circuit and CircuitStack. The former allows us to create a presenter and a ui from a screen. The latter adds the former component into the view tree.
What we’re missing is a component that’ll use that Circuit instance and render a SwiftUI view from that instance given a Screen using the corresponding presenter.
In Compose, that component is called CircuitContent. So let’s name it the same in SwiftUI for better consistency across the platforms.
In Compose, CircuitContent can be used with or without a navigator. We want the same thing in SwiftUI.
struct CircuitContent: View {
@EnvironmentObject var circuit: Circuit //(1)
private let navigator: Navigator
private let screen: Screen
init(screen: Screen) { //(2)
self.init(screen: screen, navigator: NavigatorNoOp.shared)
}
init(screen: Screen, navigator: Navigator) {
self.screen = screen
self.navigator = navigator
}
var body: some View { // (3)
CircuitView(
circuit.presenter(screen: screen, navigator: navigator),
circuit.ui(screen: screen)
)
}
}
- (1): circuit is accessible in the view tree thanks to CircuitStack
- (2): This is a convenience constructor since we don’t support navigation yet. NavigatorNoOp is a no op navigator in the iOS target of the KMP code.
- (3): We finally tie everything together by providing our CircuitView with the presenter and the UI it needs to render the SwiftUi view.
Putting everything together
Finally, we’re there. We’ve used all our SwiftUI components to watch our state coming from our Circuit presenter. This gives us a dev experience that’s very similar to the one in Compose that made us choose the Circuit library in the first place.
How does this translate to creating an iOS app? Let’s take a look.
Initializing Circuit
One best practice we follow is to use a Dependency Injection library when we use Circuit The reason for this is a production app can easily have hundreds of presenters and UIs.To keep it as simple as possible, we’llfocus on initializing dependencies manually and expose them in an AppDelegate.
@UIApplicationMain
final class AppDelegate: UIApplicationDelegate {
lazy var circuit: Circuit = initializeCircuit()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
}
extension UIApplication {
var appDelegate: AppDelegate { delegate as! AppDelegate }
}
private func initializeCircuit() -> Circuit {
return Circuit(
initializePresenterFactories(),
initializeUiFactories()
)
}
private func initializePresenterFactories() -> [PresenterFactory] {
return [
HomePresenterFactory()
]
}
private func initializeUiFactories() -> [Circuit.UiFactory] {
return [
HomeUiFactory()
]
}
Creating the iOS app
If your app is 100% SwiftUI:
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
CircuitStack(delegate.circuit){
CircuitContent(screen: HomeScreen.shared)
}
}
}
}
If your app still uses View Controllers:
let circuit = UIApplication.appDelegate.circuit
let viewController = UIHostingController(
rootView: CircuitStack(circuit) {
CircuitContent(screen: screen)
}
)
In the next part we’ll see how we can support native iOS navigation controlled by Circuit on the KMP side.
