Your shared module compiles for iOS, but an iPhone app is built in Xcode, with Swift code and Apple's tools. Something has to connect the two: Xcode must build your Kotlin code into a framework and link it into the iOS app, and Swift must be able to call it. This lesson covers the ways to do that, and how to find and add multiplatform libraries.
It covers Integrate iOS into your project with direct integration, Add dependencies on multiplatform libraries, Search for multiplatform dependencies at klibs.io and Integrate iOS with remote Swift Package Manager from the Kotlin Multiplatform programme (https://developer.android.com/courses/pathways/kotlin-multiplatform), using the JetBrains pages for direct integration (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-direct-integration.html), adding dependencies (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-add-dependencies.html) and Swift package export (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-spm-export.html), and the library index at https://klibs.io.
By the end you will be able to:
- Explain the three ways an iOS app can consume shared Kotlin code.
- Set up direct integration with a Run Script build phase.
- Call Kotlin from Swift, and know the main interop rules.
- Add multiplatform dependencies and use klibs.io to check target support.
- Describe how an XCFramework and Swift Package Manager (SPM) distribution work.
- Follow an Android-only path if you have no Mac.
Big idea: iOS integration is a build-system problem, not a language problem. Xcode asks Gradle to build the framework; Swift imports it like any other.
Honesty note: you need a Mac with Xcode to build or run the iOS app. This lesson was written without access to one, so the Xcode steps follow the official documentation rather than my own run. Xcode menus, Kotlin plugin tasks and the Swift Package Manager integration are updated often, and the SPM support in particular has been evolving. Treat the exact setting names as a guide and check the pages linked above. If you have no Mac, read this lesson for understanding and follow the Android-only path at the end.
- Shared module structure and the iOS framework block (lessons 67 and 68).
expect/actualand interfaces (lessons 66 and 68).- Basic Swift syntax: constants, functions, classes (read-only familiarity is enough).
- Coroutines and Flow (lesson 33).
| Method | Idea | Good for |
|---|---|---|
| Direct integration | Xcode runs a Gradle task during its build and links the produced framework | A monorepo where Android and iOS share one repository (the usual starting point) |
| Local Swift Package | Kotlin exports a Swift package on your machine; Xcode adds it as a local package | Teams who prefer SPM in Xcode but keep one repository |
| Remote Swift Package | You publish a built XCFramework and a Package.swift; iOS developers add the package by URL |
iOS developers who should not need a Kotlin toolchain, or sharing with other teams |
(CocoaPods integration also exists, but it is being phased out in favour of the above; you may meet it in older projects.)
Whatever you choose, the pipeline is the same:
:shared (Kotlin) ──Gradle──► SharedKit.framework / XCFramework ──Xcode──► iosApp
│
Swift: import SharedKit ◄────────┘
In shared/build.gradle.kts each iOS target must declare a framework (lesson 67). The framework name is what Swift imports.
kotlin {
val xcfName = "SharedKit"
iosArm64 {
binaries.framework {
baseName = xcfName
isStatic = true // common choice; check the current docs for your setup
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = xcfName
isStatic = true
}
}
}
The documentation notes that the Gradle task used for direct integration only exists if binaries.framework is declared for the iOS target. If the task is missing, this is the first place to look.
A static framework is linked into the app at build time; a dynamic one is a separate file loaded at run time. Static frameworks are the usual default for app code, and the page on iOS integration discusses when to choose otherwise. (I could not re-check the current guidance for isStatic while writing; follow the official page for your Kotlin version.)
Write Kotlin that is friendly to Swift
Swift sees your Kotlin API through an Objective-C-style header. A few habits make the API pleasant:
// commonMain: a facade class for iOS (also fine for Android)
class FeeApi(
private val repository: PaymentsRepository,
private val calculate: CalculateFeeSummary = CalculateFeeSummary()
) {
suspend fun loadSummary(): FeeSummary {
val payments = repository.payments()
return calculate(payments.map { it.amountNaira })
}
fun observeSummary(): Flow<FeeSummary> =
repository.observePayments().map { calculate(it.map { p -> p.amountNaira }) }
}
| Kotlin feature | How Swift sees it | Advice |
|---|---|---|
suspend fun |
An async function (recent versions) or a completion-handler function |
Fine; errors arrive as NSError unless annotated |
Flow<T> |
A Kotlin class, not a Swift AsyncSequence |
Wrap it, or use a helper library, or expose a callback |
sealed class |
Class hierarchy, no exhaustive switch |
Add helpers or consider Swift export (below) |
| Generics | Often erased to Any |
Keep public APIs concrete |
| Default arguments | Not visible | Provide overloads or factory functions |
Long, Int |
Int64, Int32 |
Be explicit |
| Top-level functions | Methods on a generated ...Kt class (for example Platform_iosKt.platform()) |
Prefer classes |
The JetBrains documentation shows exactly this convention: a file Platform.ios.kt is called from Swift as Platform_iosKt.platform().
Kotlin exceptions: unless you declare them, Kotlin exceptions crossing into Swift terminate the app. Use
@Throws(SomeException::class)on functions that may throw, so Swift receives a catchable error, or return aResult-like sealed type.
@Throws(IOException::class, CancellationException::class)
suspend fun refresh() { /* ... */ }
Wrapping a Flow for Swift
A simple callback-based wrapper works without extra libraries:
// commonMain
class FlowObserver<T>(private val flow: Flow<T>) {
private var job: Job? = null
fun start(scope: CoroutineScope, onEach: (T) -> Unit) {
job = scope.launch { flow.collect { onEach(it) } }
}
fun stop() { job?.cancel() }
}
class SummaryObserver(private val api: FeeApi) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val observer = FlowObserver(api.observeSummary())
fun start(onEach: (FeeSummary) -> Unit) = observer.start(scope, onEach)
fun stop() { observer.stop(); scope.cancel() }
}
Third-party libraries (for example SKIE or KMP-NativeCoroutines) can generate friendlier Swift wrappers; they are optional and change, so evaluate them at the time.
The JetBrains documentation describes direct integration as building the framework from an Xcode Run Script phase that calls a Gradle task named embedAndSignAppleFrameworkForXcode.
- Open the iOS project in Xcode. From Android Studio you can right-click the
.xcodeprojand choose Open In → Open in Associated Application, or open it from Xcode directly. - In Xcode choose your app target → Build Phases → + → New Run Script Phase.
- Paste a script of this form (from the documentation, adjusted to your paths):
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcodecdmust point to the root of the multiplatform project (here, one level above the iOS folder).:sharedis your shared module's name.- The
ifguard stops Xcode from building the framework a second time when Android Studio's run configuration has already built it.
- Disable "Based on dependency analysis" for this phase, so that it runs on every build.
- Drag the phase above "Compile Sources", so the framework exists before Swift is compiled.
- In Build Settings, under Build Options, set User Script Sandboxing to No. A sandboxed script cannot run Gradle. If you built before changing it, stop the sandboxed Gradle daemon with
./gradlew --stop. - If you use build configurations other than
DebugandRelease, add a User-Defined settingKOTLIN_FRAMEWORK_BUILD_TYPEset toDebugorRelease. - Make sure Xcode can find and link the framework. The documentation points to the iOS integration overview for the framework search path and linker flags used by the task's output; the project template usually sets these for you, which is why starting from the official wizard is easier than wiring them by hand.
- Build (Cmd+B).
Use it from Swift
import SwiftUI
import SharedKit
struct ContentView: View {
@State private var message = "Loading…"
var body: some View {
Text(message)
.task {
let summary = CalculateFeeSummary(termFee: 450_000)
.invoke(payments: [100_000, 50_000])
message = "Balance: \(summary.balance)"
}
}
}
(Kotlin's Long appears in Swift as Int64, and a Kotlin List<Long> as [KotlinLong], so the call above may need KotlinLong(value:) wrapping in a real project. Xcode's autocomplete shows the exact generated signatures; trust it over a tutorial.)
If you previously used CocoaPods, the documentation gives a migration: clean the build folder, run pod deintegrate, remove the cocoapods { } block from Gradle, and delete the podspec and Podfile.
Run on a simulator
In Xcode choose an iPhone simulator and Run. Android Studio can also launch the iOS run configuration on a Mac when the Kotlin Multiplatform tooling is installed. Because the Gradle task builds Kotlin each time, the first build is slow; later builds are incremental.
Shared code can only use libraries that publish a variant for each of your targets. Check before you depend on one.
Common examples
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core) // multiplatform
implementation(libs.kotlinx.serialization.json) // multiplatform
implementation(libs.kotlinx.datetime) // multiplatform
implementation(libs.ktor.client.core) // multiplatform client; engines per platform
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
When a multiplatform library publishes to Maven, Gradle metadata lets you declare it once in commonMain and Gradle selects the right artifact for each target. If a library is JVM-only, declaring it in commonMain fails on iOS, which is the error you saw in lesson 67.
Search with klibs.io
klibs.io (https://klibs.io) is a search index for Kotlin Multiplatform libraries, launched by JetBrains, that lets you search and filter by target platform, so you can see at a glance whether a library supports Android and iOS. The index aims to make multiplatform libraries easier to discover and compare. Recent JetBrains posts describe it as indexing thousands of KMP projects, but its exact size and features change, so treat its numbers as a snapshot.
A good routine before adopting any library:
- Search klibs.io for the capability (for example "datetime", "image loading", "settings").
- Filter by the targets you use (Android, iOS).
- Check last release date, stars/activity, and licence.
- Read the library's own README for setup: some need extra Gradle plugins or platform-specific configuration.
- Try it in a small spike before building on it.
Popular choices (check currency before use)
| Need | Multiplatform option |
|---|---|
| Async and streams | kotlinx.coroutines |
| JSON | kotlinx.serialization |
| HTTP | Ktor client |
| Date and time | kotlinx-datetime (and the growing stdlib time APIs) |
| Local SQL database | Room KMP (lesson 70), SQLDelight |
| Key-value storage | DataStore (multiplatform) |
| Dependency injection | Koin, kotlin-inject |
| Image loading | Coil 3 and others (verify targets) |
Swift Package Manager (SPM) is Apple's dependency tool. Sharing your Kotlin module as a Swift package means iOS developers add it in Xcode like any Swift dependency, without needing a Kotlin or Gradle setup in their project.
The building blocks
- An XCFramework: a bundle that contains your framework for several Apple targets (devices and simulators). The Kotlin Gradle plugin can assemble one from your iOS targets:
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
kotlin {
val xcf = XCFramework("SharedKit")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedKit"
xcf.add(this)
}
}
}
Running the generated task (named like assembleSharedKitReleaseXCFramework; list tasks with ./gradlew :shared:tasks to see the exact name) produces SharedKit.xcframework under the module's build/XCFrameworks folder.
- A
Package.swiftfile that tells SPM where the binary is:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "SharedKit",
platforms: [.iOS(.v15)],
products: [
.library(name: "SharedKit", targets: ["SharedKit"])
],
targets: [
.binaryTarget(
name: "SharedKit",
url: "https://github.com/your-org/shared-kit/releases/download/1.0.0/SharedKit.xcframework.zip",
checksum: "<result of swift package compute-checksum>"
)
]
)
- A place to host the zipped XCFramework: for example a GitHub Release or cloud storage, plus a Git repository containing
Package.swiftwith a version tag.
The release routine
1. Build the XCFramework with Gradle (release configuration).
2. Zip it: zip -r SharedKit.xcframework.zip SharedKit.xcframework
3. Compute checksum: swift package compute-checksum SharedKit.xcframework.zip
4. Upload the zip to a release (the URL goes in Package.swift).
5. Put the checksum and URL into Package.swift, commit, tag it 1.0.0.
6. In Xcode: File > Add Package Dependencies... > paste the repository URL.
The remote approach has a trade-off: iOS developers no longer need Kotlin, but every change to shared code needs a new release, which slows the feedback loop compared with direct integration. Many teams use direct integration while developing and SPM for releases or for other teams.
Newer tooling
The JetBrains documentation for Swift package export describes an integrated route in which the Kotlin Gradle plugin helps produce and wire Swift packages, and there is also Swift export, an effort to generate more idiomatic Swift APIs than the Objective-C bridge (better support for enums, sealed types and generics). Both have been changing from release to release and some parts are labelled experimental. Read the current page before adopting them; the manual XCFramework route above works because it relies only on stable Apple and Gradle features.
Local packages
For one repository, a local Swift package (a Package.swift with a path-based binary target or a package produced by the Gradle task) lets Xcode treat your module as a package without publishing. See the local integration page on kotlinlang.org for the current steps.
If you have no Mac you can still do almost everything in this programme:
- Build and test the shared module for Android (
androidHostTest,commonTeston the JVM). - Keep
iosArm64()andiosSimulatorArm64()declared, so the structure matches a real project. Gradle will skip iOS compilation tasks that require Apple's toolchain on non-Mac machines; the exact behaviour depends on your Kotlin version andkotlin.native.ignoreDisabledTargetssettings, so read any warning Gradle prints. - Write the
iosMaincode carefully using the Apple API names you learn from the documentation; you will not be able to compile it, but a reviewer on a Mac can. - Use a CI service with macOS runners (GitHub Actions offers them) to compile and test iOS targets on every push:
# .github/workflows/ios.yml
name: iOS build
on: [push]
jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- name: Build iOS framework and run simulator tests
run: ./gradlew :shared:iosSimulatorArm64Test :shared:linkDebugFrameworkIosSimulatorArm64
(Task names depend on your plugin versions; check ./gradlew :shared:tasks.) This is a cost-effective way to keep the iOS side honest without owning a Mac.
Exercise 1: Why is the build failing?
Xcode reports "Task 'embedAndSignAppleFrameworkForXcode' not found in project ':shared'". List two likely causes.
(1) The iOS target in shared/build.gradle.kts has no binaries.framework { } declaration, and the task only exists when it does. (2) The module name in the script (:shared) does not match the real module name, or the cd path does not point to the Gradle root, so the wrong project is used.
Exercise 2: Make the API Swift-friendly
Improve this Kotlin API for iOS callers: fun loadAll(): Flow<List<Payment>> and fun parse(raw: String): Payment that throws on bad input.
Wrap the flow in an observer class with start/stop and callbacks (or use a Swift-interop library). Annotate the throwing function with @Throws(IllegalArgumentException::class) so Swift gets a catchable error instead of a crash, or return a sealed result type. Keep types concrete, since generics are erased at the boundary.
Exercise 3: Pick a distribution method
Choose direct integration, local SPM or remote SPM for: (a) a small team in one repository developing both apps; (b) a separate iOS team in another company that must not install Gradle; (c) a CI release of version 2.1 of the shared SDK.
(a) Direct integration, for the fastest feedback. (b) Remote SPM with a published XCFramework, since they can add it by URL. (c) Remote SPM (an XCFramework attached to a tagged release), because releases need a versioned, reproducible artifact.
Mistake #1: Forgetting binaries.framework. Fix: declare it on each iOS target.
Mistake #2: Leaving User Script Sandboxing on. Fix: set it to No for the Gradle run script.
Mistake #3: Putting the Run Script phase after Compile Sources. Fix: drag it above.
Mistake #4: Wrong project root in the script. Fix: cd to the folder with gradlew.
Mistake #5: Exposing Flow and generics directly to Swift. Fix: wrap them.
Mistake #6: Letting Kotlin exceptions cross unannotated. Fix: use @Throws or return result types.
Mistake #7: Adding a JVM-only library to commonMain. Fix: check klibs.io for targets first.
Mistake #8: Publishing a Swift package without a checksum or tag. Fix: compute and commit both.
Mistake #9: Assuming iOS compiles because Android does. Fix: build iOS in CI on a macOS runner.
Mistake #10: Following old CocoaPods tutorials blindly. Fix: use the current integration pages.
Question 1. Which Gradle task does direct integration call from Xcode?
embedAndSignAppleFrameworkForXcode (run as :yourModule:embedAndSignAppleFrameworkForXcode).
Question 2. Why must the Run Script phase come before Compile Sources?
The framework must be built before Swift code that imports it is compiled.
Question 3. What does klibs.io help you do?
Search Kotlin Multiplatform libraries and filter by supported targets, so you can check Android and iOS support before adopting one.
Question 4. What is an XCFramework?
A bundle containing a framework built for several Apple targets (devices and simulators), which SPM can distribute as a binary target.
Question 5. What are two options if you have no Mac?
Build and test the Android side locally while keeping iOS targets declared, and use a macOS CI runner to compile and test the iOS targets.
Go deeper: official resources
- Kotlin Multiplatform programme
- Direct integration (JetBrains)
- Add multiplatform dependencies (JetBrains)
- Swift package export (JetBrains)
- klibs.io
Project goal: Produce everything needed for an iOS app to consume the shared FeeTracker module, to the extent your hardware allows.
Requirements:
- Declare
binaries.frameworkfor both iOS targets, naming the frameworkSharedKit. - Write a Swift-friendly facade (
FeeApi) with@Throwswhere needed, and aSummaryObserverthat wraps the flow. - With a Mac: create the Xcode project, add the Run Script phase exactly as in Build 2, disable script sandboxing, and show a screen in SwiftUI that displays the balance.
- Without a Mac: write the Swift file you would use, and add the GitHub Actions workflow that builds the iOS framework on
macos-latest. - Add the XCFramework configuration and describe the release routine for a remote Swift package in a
RELEASING.md. - Search klibs.io for three libraries FeeTracker needs (for example datetime, settings, image loading) and record their supported targets and last release.
- Write a short comparison of direct integration versus remote SPM for your team.
Stretch goal: evaluate one Swift interop helper library (for example for Flow-to-AsyncSequence conversion) and write down its pros and cons.
A RELEASING.md (example):
# Releasing SharedKit for iOS
1. ./gradlew :shared:assembleSharedKitReleaseXCFramework
2. cd shared/build/XCFrameworks/release
3. zip -r SharedKit.xcframework.zip SharedKit.xcframework
4. swift package compute-checksum SharedKit.xcframework.zip -> paste into Package.swift
5. Create a GitHub release "1.2.0", upload SharedKit.xcframework.zip
6. Update url + checksum in Package.swift, commit, tag 1.2.0, push
7. iOS app: File > Add Package Dependencies > repository URL, version rule "Up to next major 1.2.0"
A klibs.io research table (example; verify at the time):
Need Library Android iOS Last release Note
Date/time kotlinx-datetime yes yes recent first-party
Settings multiplatform-settings or DataStore yes yes recent
Images Coil 3 yes yes recent check Compose Multiplatform requirement
A SwiftUI view using the observer wrapper:
final class SummaryViewModel: ObservableObject {
@Published var balanceText = "…"
private let observer: SummaryObserver
init(api: FeeApi) {
observer = SummaryObserver(api: api)
observer.start { [weak self] summary in
DispatchQueue.main.async { self?.balanceText = "Balance: \(summary.balance)" }
}
}
deinit { observer.stop() }
}
The Android-only path is complete when CI shows a green iOS build, even though no one on the team ran the app locally.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 69 project”.
On github.com click New repository, name it android-lesson-69-integrate-with-ios-direct-integration-and, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 69 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Three Ways to Connect
- Step-by-Step Build 1: Prepare the Shared Module
- Step-by-Step Build 2: Direct Integration (Mac Only)
- Step-by-Step Build 3: Multiplatform Dependencies
- Step-by-Step Build 4: Distributing with Swift Package Manager
- Step-by-Step Build 5: The Android-Only Path (No Mac)
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check