Jetpack Compose for iOS

Getting started, step by step

Marko Novakovic
Better Programming

--

Photo by Maxime Horlaville on Unsplash

18th Apr. 2023: In face of KotlinConf 2023 Compose Multiplatform iOS support was promoted from experimental to alpha. Official docs are improved a lot but everything I show you here still stands.

27th Feb. 2023: Thank you Volodymyr Stelmashchuk for the pull request.

Updated on 13th Feb. 2023

Code:

https://github.com/marenovakovic/compose-ios

I recommend using latest Android Studio.

Jetpack Compose is arguably the biggest and best thing that happened to Android development since the beginning of Android itself; it’s right up there with Kotlin adoption — an engineering marble and an absolute joy to use.

With effort from Jetbrains to port Compose to Multiplatform, we see interesting usages for building Android and desktop apps from a single code base. But what about iOS? Turns out, that too is possible… although it is not that smooth. Now I will show you how to do it, step by step.

Prerequisites

  • It’s obvious but worth noting. You can do this only from MacOS. Yes, it is Compose, but you still need things like a simulator.
  • You may not be able to use all the libraries you are used to from your Android development: architecture components, accompanist libraries, etc. because those are not Multiplatform (yet?), but for the most part, Compose will work as you expect it to because iOS support is still experimental.

First approach

You need a module for your Compose iOS app. It can be a standalone app with the iOS app being the only module in the project (same as how you do it for native Android development), or you can add one more module to your KMM project.

If you want to create a new project for this, choose Compose Multiplatform in IntelliJ IDEA's or AndroidStudio’s new Project Wizard.

Inside the newly created module/project, copy and paste this gist to build.gradle.kts and use this gist for settings.gradle.kts.

A couple of things to explain about build.gradle.kts.

id("ord.jetbrains.compose") version "1.3.0-rc01, line 7, and maven("https://maven.pkg.jetbrains.space/public/p/compose/dev"), line 16, is how you add Compose Multiplatform to the app.

We are defining targets like so:

iosX64("uikitX64") {
binaries {
executable {
entryPoint = "main"
freeCompilerArgs += listOf(
"-linker-option", "-framework", "-linker-option", "Metal",
"-linker-option", "-framework", "-linker-option", "CoreText",
"-linker-option", "-framework", "-linker-option", "CoreGraphics"
)
}
}
}
iosArm64("uikitArm64") {
binaries {
executable {
entryPoint = "main"
freeCompilerArgs += listOf(
"-linker-option", "-framework", "-linker-option", "Metal",
"-linker-option", "-framework", "-linker-option", "CoreText",
"-linker-option", "-framework", "-linker-option", "CoreGraphics"
)
freeCompilerArgs += "-Xdisable-phases=VerifyBitcode"
}
}
}

Nothing special, right?

Now, let’s look at the compose.experimental block, which requires a bit more explanation. uikit.application allows you to set up your iOS app. You see things like bundleIdPrefix and projectName. bundleIdPrefix is like applicationId for us Android devs and projectName is pretty much self-explanatory.

Now, let’s move to deployConfiguration. You are deploying/running your iOS app with Gradle tasks; you can see those in the comments. Those are iosDeployIPhone13ProDebug and iosDeployIPadDebug. We also define the device we want this task to run our app on. The name you specify inside simulator("xxx") will determine your Gradle task for that configuration, and it will be named with xxx. The Gradle task will be iosDeployxxxDebug.

You may already be wondering what teamId is. To be honest, I’m not completely sure, but I think you need it to build the release version of your app; debug will run just fine without it. For that, you need an Apple Developer membership. You’ll get teamId from there.

That’s it for the Gradle setup. Let’s move to file structure. Inside module/src you need to create uikitMain/kotlin , and inside of it, you need main.uikit.kt file. It should look like this:

iosCompose is the name I choose. You can choose something different.

Inside main.uikit.kt, paste the following code:

This is a minimal setup, and after you look at it, it stops intimidating. For now, you don’t even need to know anything about it except that your app is written in line 43 of this gist:

window!!.rootViewController = Application("Your Application") {
Text("Helo World, from Compose iOS app!!!")
}

Application is a function that takes @Composable function. This is your entry point for the Compose world, and now you can use your Compose skills as you normally would.

The only thing left now is to run the app. But not so fast. We need one more step. You will need to do this only if you never ran any iOS app on the simulator from the Xcode.

And that’s your task for today.

Create a new iOS app in Xcode and run the app. This is required because you need a certificate. Xcode will prompt you to install it, which will require login.keychain password.

And finally, open your terminal and run ./gradlew iosDeployIPhone13ProDebug or find this task inside the Gradle tool window inside Android Studio or IntellijIDEA.

I have to mention that this approach is highly constrained. Everything you want to use has to be in same module. I wasn’t able to import @Composables from shared module and you can’t share @Composables between platforms. our module is totally self contained and we don’t have project for iOS application.

There is a better way…

Second approach

Approach that we will take a look now is much better: we would be able to reuse @Composables between platforms and we will have access to native iOS application.

For this approach we will have all our @Composables in our shared module.

We will need classic Compose Multiplatform setup for this one:

  1. settings.gradle.kts
pluginManagement {
repositories {
google()
gradlePluginPortal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
mavenCentral()
}
}

2. Project level build.gradle.kts

plugins {
//trick: for the same plugin versions in all sub-modules
id("com.android.application").version("8.1.0-alpha04").apply(false)
id("com.android.library").version("8.1.0-alpha04").apply(false)
id("org.jetbrains.compose").version("1.3.0").apply(false)
kotlin("android").version("1.8.0").apply(false)
kotlin("multiplatform").version("1.8.0").apply(false)
id("org.jetbrains.kotlin.jvm") version "1.8.0" apply false
}

allprojects {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}

3. shared module build.gradle.kts

plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
id("com.android.library")
id("org.jetbrains.compose")
}

kotlin {
android()

ios()
iosSimulatorArm64()

cocoapods {
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
version = "1.0"
ios.deploymentTarget = "14.1"
podfile = project.file("../ios/Podfile")
framework {
baseName = "shared"
isStatic = true
}
}

sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.ui)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.runtime)
}
}
val androidMain by getting
val iosMain by getting {
dependsOn(commonMain)
}
val iosSimulatorArm64Main by getting {
dependsOn(iosMain)
}
}
}

android {
namespace = "com.example.compose_ios"
compileSdk = 33
defaultConfig {
minSdk = 24
targetSdk = 33
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

And with this setup is ready. Lets jump to implementation.

Main thing here is that our @Composables inside shared module must be internal.

internal means that it will not be visible outside module it is defined in, in our case shared, so how are we going to share @Composables? We will have files main.android.kt and main.ios.kt inside shared/androidMain and shared/iosMain respectively. We will call our internal @Composables from there. Like this:

  1. shared/commonMain. This is our actual application, one that we will share across platforms.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@Composable
internal fun ExampleApplication() {
MaterialTheme {
Scaffold(
topBar = { TopAppBar { Text("Hello") } },
content = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("From Compose!")
}
},
)
}
}

2. shared/androidMain, main.android.kt. This will be visible from our Android application/project.

import androidx.compose.runtime.Composable

@Composable
fun Application() {
ExampleApplication()
}

3. shared/iosMain, main.ios.kt. Here are are defining ViewController that will be visible from our iOS application/project.

import androidx.compose.ui.window.Application
import platform.UIKit.UIViewController

fun MainViewController(): UIViewController =
Application("Example Application") {
ExampleApplication()
}

Same as in first approach, Application is our entry to Compose world.

Setting this ViewController for iOS app:

//iosApp.swift

import SwiftUI
import shared

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let mainViewController = Main_iosKt.MainViewController()
window?.rootViewController = mainViewController
window?.makeKeyAndVisible()
return true
}
}

4. Hit that run button. In this case we don’t use that special gradle task. Just run the ios configuration that’s created for us on project creation.

--

--