Write Once, Run Everywhere: Building with Kotlin and Compose Multiplatform

vladleesi

Vladislav Kochetov

Posted on July 28, 2023

Write Once, Run Everywhere: Building with Kotlin and Compose Multiplatform

Welcome to the world of Kotlin multiplatform app development! In this article, we'll explore a simple example of an app built entirely with Kotlin. We'll leverage the power of Kotlin multiplatform, Compose multiplatform, Kotlin Coroutines, Kotlin Serialization, and Ktor to create an app that runs smoothly on both Android and iOS platforms.

The focus here is on creating a multiplatform app that uses shared network logic and user interface only just on Kotlin.

We require macOS, Android Studio, Kotlin Multiplatform Mobile plugin, Xcode, and a dash of enthusiasm!

Disclaimer: I aim to focus solely on essential aspects. For the complete code, please refer to the following GitHub repository: https://github.com/vladleesi/factastic

So, let's get started!

To begin, let's create a multiplatform project in Android Studio by selecting "New Project" and then choosing the "Kotlin Multiplatform App" template.

Creation of new multiplatform project

After creating the multiplatform project in Android Studio, the next step is to add all the necessary dependencies and plugins.



// gradle.properties
org.jetbrains.compose.experimental.uikit.enabled=true
kotlin.native.cacheKind=none

// build.gradle.kts (app)
plugins {
    kotlin("multiplatform").apply(false)
    id("com.android.application").apply(false)
    id("com.android.library").apply(false)
    id("org.jetbrains.compose").apply(false)
    kotlin("plugin.serialization").apply(false)
}


// settings.gradle.kts
plugins {
    val kotlinVersion =extra["kotlin.version"] as String
    val gradleVersion =extra["gradle.version"] as String
    val composeVersion =extra["compose.version"] as String

    kotlin("jvm").version(kotlinVersion)
    kotlin("multiplatform").version(kotlinVersion)
    kotlin("plugin.serialization").version(kotlinVersion)
    kotlin("android").version(kotlinVersion)

    id("com.android.application").version(gradleVersion)
    id("com.android.library").version(gradleVersion)
    id("org.jetbrains.compose").version(composeVersion)
}

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


Enter fullscreen mode Exit fullscreen mode

After setting up versions, we proceed to work on the platform-specific 'shared' module.



// build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("com.android.library")
    id("org.jetbrains.compose")
    kotlin("plugin.serialization")
}

listOf(
    iosX64(),
    iosArm64(),
    iosSimulatorArm64()
).forEach {
    it.binaries.framework {
         baseName = "shared"
         // IMPORTANTE: Include a static library instead of a dynamic one into the framework.
         isStatic = true
    }
}

val commonMain by getting {
     dependencies {
         // Compose Multiplatform
         implementation(compose.runtime)
         implementation(compose.foundation)
         implementation(compose.material)
         @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
         implementation(compose.components.resources)

         /.../
     }
}


Enter fullscreen mode Exit fullscreen mode

And add dependencies for Android & iOS http client specifics.



val androidMain by getting {
     dependencies {
         val ktorVersion = extra["ktor.version"] as String
         implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
      }
}
val iosMain by getting {
      dependencies {
         val ktorVersion = extra["ktor.version"] as String
         implementation("io.ktor:ktor-client-darwin:$ktorVersion")
      }
}


Enter fullscreen mode Exit fullscreen mode

Now, it's time to dive into the UI development. We'll focus on creating a simple UI featuring a button to generate random useless facts from a server.



// shared/../FactasticApp.kt
@Composable
fun FactasticApp(viewModel: AppViewModel, modifier: Modifier = Modifier) {
    FactasticTheme {
        Surface(
            modifier = modifier.fillMaxSize(),
            color = MaterialTheme.colors.background
        ) {
            val state = viewModel.stateFlow.collectAsState()
            LaunchedEffect(Unit) {
                viewModel.loadUselessFact()
            }
            MainScreen(state.value, viewModel::onClick)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Business logic inside AppViewModel is here.
Configuration of Ktor client is here.

Behold, the moment for the grand trick has arrived.

Android



class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel = AppViewModel()
        setContent {
            FactasticApp(viewModel)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

iOS

Let's adapt our Compose code for iOS.



// shared/iosMain/../FactasticApp.kt

fun MainViewController(): UIViewController {
    val viewModel = AppViewModel()
    return ComposeUIViewController {
        FactasticApp(viewModel)
    }
}


Enter fullscreen mode Exit fullscreen mode

Next, we should open Xcode to work on the iOS part, as we need to perform some Swift-related tasks. Locate iosApp/iosApp.xcodeproj, right-click on it, and then choose "Open in" -> "Xcode."

In Xcode, create a new Swift file named "ComposeView.swift" by clicking on "File" -> "New" -> "File..." -> "Swift File" -> "Next" -> "ComposeView.swift" -> "Create."

Oh my God, what is this? Is it Swift?



// ComposeView.swift

import Foundation
import SwiftUI
import shared

struct ComposeView: UIViewControllerRepresentable {
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        // ignore
    }

    func makeUIViewController(context: Context) -> some UIViewController {
        FactasticAppKt.MainViewController()
    }
}


Enter fullscreen mode Exit fullscreen mode

Make a minor update to the existing ContentView.swift file in Xcode.



import SwiftUI
import shared

struct ContentView: View {
   var body: some View {
      // This one
      ComposeView()
   }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


Enter fullscreen mode Exit fullscreen mode

That's all the Xcode part, you may forget about it (if you can) and return to Android Studio.

Before running the app, we must build the iOS module using the following command: ./gradlew :shared:compileKotlinIosArm64.

In Android Studio, select the target platform, and then click on the "Run" button to launch the application on the chosen platform.

Choosing target platform for launch

Well done!

Android (Dark theme) iOS (Light theme)
Android (Dark theme) iOS (Light theme)

Resources:

Update as of 08/02/2023:
Additionally, I have incorporated the desktop module into the project.

P.S.
I'd greatly appreciate receiving feedback. If my examples happen to be useful to you, please, consider giving a star to the GitHub repository. Thank you!

💖 💪 🙅 🚩
vladleesi
Vladislav Kochetov

Posted on July 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related