Skip to main content
A Duet-built iPhone app on the Kotlin Multiplatform flavor is a SwiftUI app over a prebuilt Kotlin core. The feature logic runs inside one XCFramework that Gradle assembles from the shared Kotlin modules. The Swift code around it is split into Swift packages: a Kit package that links the framework and adapts each Kotlin store for SwiftUI, a Main package that composes the app, two theming packages, and an app target that holds only what the operating system’s entry objects must. Duet, Modaal’s cross-platform parity framework for native iOS and Android apps, scaffolds this shape. This page walks the src-ios/ tree; Inside a Duet Android app walks the same repository’s Android side.
The worked example is Memory Lane, live on the App Store, the same app the Android page walks. Its package names begin with the project’s name; this page writes that prefix as <Project>, the placeholder the scaffold fills with your app’s name. Counts come from Memory Lane’s repository.

What does the tree look like?

The iPhone-relevant parts of Memory Lane’s tree:
The code under src-ios/ is Swift. The iPhone app contains no copy of a reducer: the feature logic is compiled once from src-kmp/ and linked as the XCFramework. Memory Lane’s src-ios/ holds 250 Swift files outside generated directories, 208 of them in the Main package.

What is in the Kit package?

src-ios/Libraries/<Project>Kit/ is where Swift meets the Kotlin core. Its manifest declares the framework as a binary target at the path assemble_kit.sh publishes to, and a bridge target that every shell depends on:
src-ios/Libraries/<Project>Kit/Package.swift (as the scaffold emits it)
  • The binary target is a build product of the project’s own Kotlin sources. build/ is not committed, so a fresh clone has no framework until the first assemble; How do you build it? covers when that happens.
  • The bridge holds BridgedStore, the one class every shell uses to hold a Kotlin store: a @Published mirror of the store’s state, a synchronous send, and a cancel() that stops the Kotlin runtime. Tutorial 2 writes it.
  • The shells. The scaffold emits each feature’s iOS side into this package as a <Feature>Shell target: a Builder that makes the Kotlin store and the bridged mirror, a ViewShell that turns intents into actions and state into view state, the SwiftUI view, and a spec target that tests the shell. Memory Lane keeps its 14 shell directories in the Main package’s Shells/ instead; both layouts depend on the same bridge.
The Kotlin/Native collector frees a Kotlin object that Swift holds, on a collection after the last Swift reference is released. Kotlin/Native GC and Swift ARC covers what that framework links into the app and who frees what.

What is in the Main package?

src-ios/Libraries/<Project>Main/ composes the app. It depends on the Kit package, the two theming packages and the Duet shells and services packages, and it holds:
  • The composition root. RootBuilder builds the root level: a RootComponent that owns the app-lifetime services (diagnostics, analytics, the inbound URL and push registry), adopts them as workers in a StoreHost, and then mounts the features. Composition is a Dependency, a Component and a Builder per level, the same three parts the Android app uses (Tutorial 3).
  • Mounts. The scaffold writes one Features/<Feature>Mount.swift per feature: a function that calls the feature’s Builder, wraps the view in a MountedFeature, and gives the scene the mount’s activate and deactivate bracket. A child’s delegate events are routed in that file’s onDelegate closure.
  • The root screen. RootScreen renders the mounted features. As emitted it shows one feature full-screen, with a tab strip once there are two; the app’s real navigation replaces it as the product grows.
  • Workers. Everything that touches the world, implemented in Swift behind the ports the Kotlin reducers name as effects. Memory Lane’s Workers/ has 21 of them: repository workers over its backend, push notifications, media storage, image downscaling, video transcoding, widget sync.
The Android counterparts of these parts are the builders, MainNavHost.kt and workers/ in the Android app module.

Where do the theme and the controls live?

  • Libraries/Theming/ holds the app’s theme. Its semantic colors, fonts and gradients are generated by duet design-tokens from parity/design-tokens.yaml, the file the Android theme is generated from too, so both apps read the same token names. A hand edit to a generated file fails duet design-tokens --check.
  • Libraries/ThemedControls/ holds the shared controls built on the theme. The scaffold emits AppButton, the one action-button style every screen uses, with primary, secondary and destructive roles. Memory Lane keeps its button, MLButton, in the Theming package.
Tutorial 7 builds the tokens and the generated code.

What is in the app target?

src-ios/App/<Project>/ holds what only the app’s entry objects can own:
  • SceneDelegate builds the root with RootBuilder(dependency: SceneComponent()).build(), puts RootScreen in a UIHostingController inside the theme’s scope, calls activate(), and calls teardown() when the scene disconnects. In Memory Lane it also forwards every opened URL to the app-services registry the root returned.
  • SceneComponent is the root level’s Dependency: the objects only the scene can supply. As emitted it is empty.
  • AppDelegate carries process-level duties; in Memory Lane, the launch services and the push-notification device token.
The Xcode project is generated: xcodegen.yml is the source, and the .xcodeproj is a build product. The spec gives the app scheme two build pre-actions, which run before Xcode plans the build:
  1. Increment BUILD_NUMBER, for builds other than Debug.
  2. Assemble the Kotlin core: scripts/assemble_kit.sh debug for a Debug build and release otherwise, so the build links the framework that contains the latest Kotlin edit.
The scaffold emits Debug and Release configurations. Memory Lane builds with three, Debug, AdHoc and AppStore, each with its own file in xcconfig/ over a shared Base.xcconfig, and adds a WidgetKit extension, <Project>Widgets, that reads what the app writes through the WidgetShared package’s App Group storage.
Side by side: Memory Lane's registration screen in SwiftUI on an iPhone — hand-drawn clouds, a portrait illustration, Continue with Apple and Continue with Google buttons — and in Jetpack Compose on a Pixel 8 emulator — the same serif tagline with a single Continue with Google button

Memory Lane's registration screen on an iPhone simulator and a Pixel 8 emulator. One shared reducer owns the flow on both platforms; the iPhone app's Main package composes the SwiftUI screen, and only iOS offers Sign in with Apple.

How do you build it?

Open the generated project in Xcode, or let Modaal build and run it. Every build through the app scheme runs the assemble pre-action first. The first assemble on a machine downloads the Kotlin/Native toolchain and takes several minutes; later ones take seconds. A fresh clone needs one assemble before Xcode opens the project, because Swift package resolution reads the binary target before any pre-action runs:
Build simulator targets with ARCHS=arm64 on the xcodebuild command line: the framework ships arm64 slices only, and a generic simulator build also compiles x86_64. A concrete simulator destination is arm64 already.

What does day one look like?

On the day the scaffold runs, the same shape is there at minimal size:
  • <Project>Kit with the binary target and <Project>Bridge;
  • <Project>Main with RootComposition.swift (the root’s Dependency, Component and Builder), RootScreen.swift, the analytics and ingress workers, and a test target;
  • Theming and ThemedControls;
  • the app target with its three entry files and the scheme’s pre-actions.
Each feature the scaffold adds is one <Feature>Shell target in the Kit package, with its spec target, and one Features/<Feature>Mount.swift in the Main package. The Kotlin side of the same feature is a module under src-kmp/subtrees/, and its recordings are under parity/fixtures/.

Common questions

A Swift package builds and tests without the app. swift test in the Kit package runs every shell’s spec against the linked Kotlin core, and the Main package has its own test target for the composition root. The app target stays three files; the shells, the composition root and the workers are in packages with test targets.
In the Kotlin modules under src-kmp/subtrees/, compiled into the XCFramework. The Swift side sends actions, renders state and performs effects through workers; every decision a recording pins is made in a Kotlin reducer. Inside a Duet Android app shows a feature module.
Yes. The XCFramework links the Kotlin/Native runtime, including its collector, into the app executable. Kotlin/Native GC and Swift ARC measures its pauses and states the rules for Swift code that holds Kotlin objects.
Edit src-ios/App/xcodegen.yml and regenerate. The project file is generated from it, so an edit made in Xcode’s project editor is lost at the next xcodegen generate.
On the Swift flavor the feature logic is Swift: each feature is a Swift package under src-ios/Subtrees/, with no binary target and no Kotlin/Native runtime. The Duet overview compares the two flavors.
Modaal scaffolds this tree for you — say you’re building a Production app for Android + iOS in the sentence on the home screen.

Sources and further reading

Inside a Duet Android app

The same repository’s Android side: feature modules in commonMain, a thin Compose app module, builders and workers.

Kotlin/Native GC and Swift ARC

What the Kit package’s framework links into the app, the collector’s measured pauses, and who frees what.

Tutorial 2: One Behavior, Two Apps

The Kit package built by hand: the framework, the bridge, the first shell and the iPhone app.

Duet glossary

Shell, Builder, Component, mount, worker and the other terms on this page.
Last modified on October 2, 2026