Create a multiplatform app using Ktor and SQLDelight
This tutorial demonstrates how to use Android Studio to create an advanced mobile application for iOS and Android using Kotlin Multiplatform. This application is going to:
Retrieve data over the internet from the public SpaceX API using Ktor
Save the data in a local database using SQLDelight.
Display a list of SpaceX rocket launches together with the launch date, results, and a detailed description of the launch.
The application will include a module with shared code for both the iOS and Android platforms. The business logic and data access layers will be implemented only once in the shared module, while the UI of both applications will be native.
You will use the following multiplatform libraries in the project:
Ktor as an HTTP client for retrieving data over the internet.
kotlinx.serialization
to deserialize JSON responses into objects of entity classes.kotlinx.coroutines
to write asynchronous code.SQLDelight to generate Kotlin code from SQL queries and create a type-safe database API.
Koin to provide platform-specific database drivers via dependency injection.
Create your project
Prepare your environment for multiplatform development. Check the list of necessary tools and update them to the latest versions if necessary.
Open the Kotlin Multiplatform wizard.
On the New project tab, ensure that the Android and iOS options are selected.
For iOS, choose the Do not share UI option. You will implement native UI for both platforms.
Click the Download button and unpack the downloaded archive.
Launch Android Studio.
On the Welcome screen, click Open, or select File | Open in the editor.
Navigate to the unpacked project folder and then click Open.
Android Studio detects that the folder contains a Gradle build file, opens the folder as a new project, and starts the initial Gradle Sync.
The default view in Android Studio is optimized for Android development. To see the full file structure of the project, which is more convenient for multiplatform development, switch the view from Android to Project:
Add Gradle dependencies
To add a multiplatform library to the shared module, you need to add dependency instructions (implementation
) to the dependencies {}
block of the relevant source sets in the build.gradle.kts
file.
Both the kotlinx.serialization
and SQLDelight libraries also require additional configuration.
Change or add lines in the version catalog in the gradle/libs.versions.toml
file to reflect all needed dependencies:
In the
[versions]
block, check the AGP version and add the rest:[versions] agp = "8.2.2" ... coroutinesVersion = "1.7.3" dateTimeVersion = "0.6.0" koin = "3.5.3" ktor = "2.3.7" sqlDelight = "2.0.1" lifecycleViewmodelCompose = "2.7.0" material3 = "1.2.0"In the
[libraries]
block, add the following library references:[libraries] ... android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTimeVersion" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref="material3" }In the
[plugins]
block, specify the necessary Gradle plugins:[plugins] ... kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }Once the dependencies are added, you're prompted to resync the project. Click Sync Now to synchronize Gradle files:
At the very beginning of the
shared/build.gradle.kts
file, add the following lines to theplugins {}
block:plugins { // ... alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.sqldelight) }In the same
shared/build.gradle.kts
file, refer to all the required dependencies:kotlin { // ... sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.runtime) implementation(libs.kotlinx.datetime) implementation(libs.koin.core) } androidMain.dependencies { implementation(libs.ktor.client.android) implementation(libs.android.driver) } iosMain.dependencies { implementation(libs.ktor.client.darwin) implementation(libs.native.driver) } } }The common source set requires a core artifact of each library, as well as the Ktor serialization feature to use
kotlinx.serialization
for processing network requests and responses.The iOS and Android source sets also need SQLDelight and Ktor platform drivers.
Once the dependencies are added, click Sync Now to synchronize Gradle files once again.
After the Gradle sync, you are done with the project configuration and can start writing code.
Create an application data model
The tutorial app will contain the public SpaceXSDK
class as the facade over networking and cache services. The application data model will have three entity classes with:
General information about the launch
Links to images of mission patches
URLs of articles related to the launch
Create the necessary data classes:
In the
shared/src/commonMain/kotlin
directory, create a directory with the namecom/jetbrains/spacetutorial
, to make nested package folders.In the
com.jetbrains.spacetutorial
directory, create theentity
directory with theEntity.kt
file inside.Declare all the data classes for basic entities:
import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RocketLaunch( @SerialName("flight_number") val flightNumber: Int, @SerialName("name") val missionName: String, @SerialName("date_utc") val launchDateUTC: String, @SerialName("details") val details: String?, @SerialName("success") val launchSuccess: Boolean?, @SerialName("links") val links: Links ) { var launchYear = launchDateUTC.toInstant().toLocalDateTime(TimeZone.UTC).year } @Serializable data class Links( @SerialName("patch") val patch: Patch?, @SerialName("article") val article: String? ) @Serializable data class Patch( @SerialName("small") val small: String?, @SerialName("large") val large: String? )
Each serializable class must be marked with the @Serializable
annotation. The kotlinx.serialization
plugin automatically generates a default serializer for @Serializable
classes unless you explicitly pass a link to a serializer in the annotation argument.
The @SerialName
annotation allows you to redefine field names, which helps to access properties in data classes using more readable identifiers.
Configure SQLDelight and implement cache logic
Configure SQLDelight
The SQLDelight library allows you to generate a type-safe Kotlin database API from SQL queries. During compilation, the generator validates the SQL queries and turns them into Kotlin code that can be used in the shared module.
The SQLDelight dependency is already included in the project. To configure the library, open the shared/build.gradle.kts
file and add the sqldelight {}
block in the end. This block contains a list of databases and their parameters:
The packageName
parameter specifies the package name for the generated Kotlin sources.
Sync the Gradle project files when prompted.
Generate the database API
First, create the .sq
file with all the necessary SQL queries. By default, the SQLDelight plugin looks for .sq
files in the sqldelight
folder of the source set:
In the
shared/src/commonMain
directory, create a newsqldelight
directory.Inside the
sqldelight
directory, create a new directory with the namecom/jetbrains/spacetutorial/cache
to create nested directories for the package.Inside the
cache
directory, create theAppDatabase.sq
file (with the same name as the database you specified in thebuild.gradle.kts
file). All the SQL queries for your application will be stored in this file.The database will contain a table with data about launches. Add the following code for creating the table to the
AppDatabase.sq
file:import kotlin.Boolean; CREATE TABLE Launch ( flightNumber INTEGER NOT NULL, missionName TEXT NOT NULL, details TEXT, launchSuccess INTEGER AS Boolean DEFAULT NULL, launchDateUTC TEXT NOT NULL, patchUrlSmall TEXT, patchUrlLarge TEXT, articleUrl TEXT );Add the
insertLaunch
function for inserting data into the table:insertLaunch: INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl) VALUES(?, ?, ?, ?, ?, ?, ?, ?);Add the
removeAllLaunches
function for clearing data in the table:removeAllLaunches: DELETE FROM Launch;Declare the
selectAllLaunchesInfo
function for retrieving data:selectAllLaunchesInfo: SELECT Launch.* FROM Launch;Generate the corresponding
AppDatabase
interface (which you will initialize with database drivers later on). To do that, run the following command in the terminal:./gradlew generateCommonMainAppDatabaseInterfaceThe generated Kotlin code is stored in the
shared/build/generated/sqldelight
directory.
Create factories for platform-specific database drivers
To initialize the AppDatabase
interface, you will pass an SqlDriver
instance to it. SQLDelight provides multiple platform-specific implementations of the SQLite driver, so you need to create these instances separately for each platform.
While you can achieve this with expected and actual interfaces, in this project, you will use Koin to try dependency injection in Kotlin Multiplatform.
Create an interface for database drivers. To do this, in the
shared/src/commonMain/kotlin
directory, create a directory with the namecom/jetbrains/spacetutorial/cache
(this will add thecache
directory to the package).Create the
DatabaseDriverFactory
interface inside thecache
directory:package com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver interface DatabaseDriverFactory { fun createDriver(): SqlDriver }Create the class implementing this interface for Android: in the
shared/src/androidMain/kotlin
directory, create thecom.jetbrains.spacetutorial.cache
package with theDatabaseDriverFactory.kt
file inside.On Android, the SQLite driver is implemented by the
AndroidSqliteDriver
class. In theDatabaseDriverFactory.kt
file, pass the database information and the context link to theAndroidSqliteDriver
class constructor:package com.jetbrains.spacetutorial.cache import android.content.Context import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return AndroidSqliteDriver(AppDatabase.Schema, context, "launch.db") } }For iOS, in the
shared/src/iosMain/kotlin
directory, create a directory with the namecom/jetbrains/spacetutorial/cache
.Inside this directory, create the
DatabaseDriverFactory.kt
file and add this code:package com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver class IOSDatabaseDriverFactory : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return NativeSqliteDriver(AppDatabase.Schema, "launch.db") } }
You will implement instances of these drivers later in the platform-specific code of your project.
Implement cache
So far, you have added factories for platform database drivers and an AppDatabase
interface to perform database operations. Now, create a Database
class, which will wrap the AppDatabase
interface and contain the caching logic.
In the common source set
shared/src/commonMain/kotlin
, create a newDatabase
class in thecom.jetbrains.spacetutorial.cache
directory. It will contain logic common to both platforms.To provide a driver for
AppDatabase
, pass an abstractDatabaseDriverFactory
instance to theDatabase
class constructor:package com.jetbrains.spacetutorial.cache internal class Database(databaseDriverFactory: DatabaseDriverFactory) { private val database = AppDatabase(databaseDriverFactory.createDriver()) private val dbQuery = database.appDatabaseQueries }This class's visibility is set to internal, which means it is only accessible from within the multiplatform module.
Inside the
Database
class, implement some data handling operations. First, create thegetAllLaunches
function to return a list of all the rocket launches. ThemapLaunchSelecting
function is used to map the result of the database query toRocketLaunch
objects:import com.jetbrains.spacetutorial.entity.Links import com.jetbrains.spacetutorial.entity.Patch import com.jetbrains.spacetutorial.entity.RocketLaunch internal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... internal fun getAllLaunches(): List<RocketLaunch> { return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList() } private fun mapLaunchSelecting( flightNumber: Long, missionName: String, details: String?, launchSuccess: Boolean?, launchDateUTC: String, patchUrlSmall: String?, patchUrlLarge: String?, articleUrl: String? ): RocketLaunch { return RocketLaunch( flightNumber = flightNumber.toInt(), missionName = missionName, details = details, launchDateUTC = launchDateUTC, launchSuccess = launchSuccess, links = Links( patch = Patch( small = patchUrlSmall, large = patchUrlLarge ), article = articleUrl ) ) } }Add the
clearAndCreateLaunches
function to clear the database and insert new data:internal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... internal fun clearAndCreateLaunches(launches: List<RocketLaunch>) { dbQuery.transaction { dbQuery.removeAllLaunches() launches.forEach { launch -> dbQuery.insertLaunch( flightNumber = launch.flightNumber.toLong(), missionName = launch.missionName, details = launch.details, launchSuccess = launch.launchSuccess ?: false, launchDateUTC = launch.launchDateUTC, patchUrlSmall = launch.links.patch?.small, patchUrlLarge = launch.links.patch?.large, articleUrl = launch.links.article ) } } } }
Implement an API service
To retrieve data over the internet, you'll use the SpaceX public API and a single method to retrieve the list of all launches from the v5/launches
endpoint.
Create a class that will connect the application to the API:
In the common source set
shared/src/commonMain/kotlin
, create a directory with the namecom/jetbrains/spacetutorial/network
.Inside the
network
directory, create theSpaceXApi
class:package com.jetbrains.spacetutorial.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json class SpaceXApi { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true useAlternativeNames = false }) } } }This class executes network requests and deserializes JSON responses into entities from the
com.jetbrains.spacetutorial.entity
package. The KtorHttpClient
instance initializes and stores thehttpClient
property.This code uses the Ktor
ContentNegotiation
plugin to deserialize the result of aGET
request. The plugin processes the request and the response payload as JSON, serializing and deserializing them as needed.
Declare the data retrieval function that returns the list of rocket launches:
class SpaceXApi { // ... suspend fun getAllLaunches(): List<RocketLaunch> { return httpClient.get("https://api.spacexdata.com/v5/launches").body() } }The
getAllLaunches
function has thesuspend
modifier because it contains a call of the suspend functionHttpClient.get()
. Theget()
function includes an asynchronous operation to retrieve data over the internet and can only be called from a coroutine or another suspend function. The network request will be executed in the HTTP client's thread pool.The URL for sending a GET request is passed as an argument to the
get()
function.
Build an SDK
Your iOS and Android applications will communicate with the SpaceX API through the shared module, which will provide a public class, SpaceXSDK
.
In the common source set
shared/src/commonMain/kotlin
, in thecom.jetbrains.spacetutorial
directory, create theSpaceXSDK
class. This class will be the facade for theDatabase
andSpaceXApi
classes.To create a
Database
class instance, provide aDatabaseDriverFactory
instance:package com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.Database import com.jetbrains.spacetutorial.cache.DatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { private val database = Database(databaseDriverFactory) }You will inject the correct database driver in the platform-specific code through the
SpaceXSDK
class constructor.Add the
getLaunches
function, which uses the created database and the API to get the launches list:import com.jetbrains.spacetutorial.entity.RocketLaunch class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { // ... @Throws(Exception::class) suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { val cachedLaunches = database.getAllLaunches() return if (cachedLaunches.isNotEmpty() && !forceReload) { cachedLaunches } else { api.getAllLaunches().also { database.clearAndCreateLaunches(it) } } } }
The class contains one function for getting all launch information. Depending on the value of
forceReload
, it returns cached values or loads the data from the internet and then updates the cache with the results. If there is no cached data, it loads the data from the internet regardless of theforceReload
flag's value.Clients of your SDK could use a
forceReload
flag to load the latest information about the launches, enabling the pull-to-refresh gesture for users.All Kotlin exceptions are unchecked, while Swift has only checked errors (see Interoperability with Swift/Objective-C for details). Thus, to make your Swift code aware of expected exceptions, Kotlin functions called from Swift should be marked with the
@Throws
annotation specifying a list of potential exception classes.
Create the Android application
The Kotlin Multiplatform wizard handles the initial Gradle configuration for you, so the shared
module is already connected to your Android application.
Before implementing the UI and the presentation logic, add all the required UI dependencies to the composeApp/build.gradle.kts
file:
Sync the Gradle project files when prompted.
Add internet access permission
To access the internet, an Android application needs the appropriate permission. In the composeApp/src/androidMain/AndroidManifest.xml
file, add the <uses-permission>
tag:
Add dependency injection
The Koin dependency injection lets you declare modules (sets of components) that you can use in different contexts. In this project, you will create two modules: one for the Android application and another for the iOS app. Then, you will start Koin for each native UI using the corresponding module.
Declare a Koin module that will contain the components for the Android app:
In the
composeApp/src/androidMain/kotlin
directory, create theAppModule.kt
file in thecom.jetbrains.spacetutorial
package.In that file, declare the module as two singletons, one for the
SpaceXApi
class and one for theSpaceXSDK
class:package com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.AndroidDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val appModule = module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = AndroidDatabaseDriverFactory( androidContext() ), api = get() ) } }The
SpaceXSDK
class constructor is injected with the platform-specificAndroidDatabaseDriverFactory
class. Theget()
function resolves dependencies within the module: in place of theapi
parameter forSpaceXSDK()
, Koin will pass theSpaceXApi
singleton declared earlier.Create a custom
Application
class, which will start the Koin module.Next to the
AppModule.kt
file, create theApplication.kt
file with the following code, specifying the module you declared in themodules()
function call:package com.jetbrains.spacetutorial import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext.startKoin class MainApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@MainApplication) modules(appModule) } } }Specify the
MainApplication
class you created in the<application>
tag of yourAndroidManifest.xml
file:<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... android:name="com.jetbrains.spacetutorial.MainApplication"> ... </application> </manifest>
Now, you are ready to implement the UI that will use the information provided by the platform-specific database driver.
Prepare the view model with the list of launches
You will implement the Android UI using Jetpack Compose and Material 3. First, you'll create the view model that uses the SDK to get the list of launches. Then, you'll set up the Material theme, and finally, you'll write the composable function that brings it all together.
In the
composeApp/src/androidMain
source set, in thecom.jetbrains.spacetutorial
package, create theRocketLaunchViewModel.kt
file:package com.jetbrains.spacetutorial import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.jetbrains.spacetutorial.entity.RocketLaunch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { private val _state = mutableStateOf(RocketLaunchScreenState()) val state: State<RocketLaunchScreenState> = _state } data class RocketLaunchScreenState( val isLoading: Boolean = false, val launches: List<RocketLaunch> = emptyList() )A
RocketLaunchScreenState
instance will store data received from the SDK and the current state of the request.Add the
loadLaunches
function that will call thegetLaunches
function of the SDK in a coroutine scope of this view model:import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { //... fun loadLaunches() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, launches = emptyList()) try { val launches = sdk.getLaunches(forceReload = true) _state.value = _state.value.copy(isLoading = false, launches = launches) } catch (e: Exception) { _state.value = _state.value.copy(isLoading = false, launches = emptyList()) } } } }Then add a
loadLaunches()
call to theinit {}
block of the class to request data from the API as soon as theRocketLaunchViewModel
object is created:class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { // ... init { loadLaunches() } }Now, in the
AppModule.kt
file, specify the view model in the Koin module:import org.koin.androidx.viewmodel.dsl.viewModel val appModule = module { // ... viewModel { RocketLaunchViewModel(sdk = get()) } }
Build the Material Theme
You will build your main App()
composable around the AppTheme
function supplied by a Material Theme:
You can generate a theme for your Compose app using the Material Theme Builder. When you're done picking colors, click Export in the top right corner and select the Jetpack Compose (Theme.kt) option.
Unpack the archive and copy the
theme
folder into thecomposeApp/src/androidMain/kotlin/com/jetbrains/spacetutorial
directory.In each theme file,
Color.kt
andTheme.kt
, change thepackage com.example.compose
line to refer to your package:package com.jetbrains.spacetutorial.themeIn the
Color.kt
file, add two variables for colors you are going to use for successful and unsuccessful launches:val app_theme_successful = Color(0xff4BB543) val app_theme_unsuccessful = Color(0xffFC100D)
Implement the presentation logic
Create the main App()
composable for your application, and call it from a ComponentActivity
class:
Create the
App.kt
file next to thetheme
directory in thecom.jetbrains.spacetutorial
package and add theApp()
composable function:package com.jetbrains.spacetutorial import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import org.koin.androidx.compose.koinViewModel @OptIn( ExperimentalMaterial3Api::class ) @Composable fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val pullToRefreshState = rememberPullToRefreshState() if (pullToRefreshState.isRefreshing) { viewModel.loadLaunches() pullToRefreshState.endRefresh() } }Here, you are using the Koin ViewModel API to refer to the
viewModel
you declared in the Android Koin module.Now add the UI code that will implement the loading screen, the column of launch results, and the pull-to-refresh action:
import com.jetbrains.spacetutorial.theme.AppTheme import com.jetbrains.spacetutorial.entity.RocketLaunch import com.jetbrains.spacetutorial.theme.app_theme_successful import com.jetbrains.spacetutorial.theme.app_theme_unsuccessful import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp @Composable fun App() { // ... AppTheme { Scaffold( topBar = { TopAppBar(title = { Text( "SpaceX Launches", style = MaterialTheme.typography.headlineLarge ) }) } ) { padding -> Box( modifier = Modifier .nestedScroll(pullToRefreshState.nestedScrollConnection) .fillMaxSize() .padding(padding) ) { if (state.isLoading) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Text("Loading...", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn { items(state.launches) { launch: RocketLaunch -> Column(modifier = Modifier.padding(all = 16.dp)) { Text( text = "${launch.missionName} - ${launch.launchYear}", style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(8.dp)) Text( text = if (launch.launchSuccess == true) "Successful" else "Unsuccessful", color = if (launch.launchSuccess == true) app_theme_successful else app_theme_unsuccessful ) Spacer(Modifier.height(8.dp)) val details = launch.details if (details?.isNotBlank() == true) { Text( text = details ) } } HorizontalDivider() } } } PullToRefreshContainer( state = pullToRefreshState, modifier = Modifier.align(Alignment.TopCenter) ) } } } }Remove the
import App
line in theMainActivity.kt
file in thecom.jetbrains.spacetutorial
package so that thesetContent()
function refers to theApp()
composable you just created in that package.Finally, specify your
MainActivity
class in theAndroidManifest.xml
file:<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... <activity ... android:name="com.jetbrains.spacetutorial.MainActivity"> ... </activity> </application> </manifest>Run your Android app: select composeApp from the run configurations menu, choose an emulator, and click the run button. The app automatically runs the API request and displays the list of launches (the background color depends on the Material Theme you generated):
You've just created an Android application that has its business logic implemented in the Kotlin Multiplatform module, and its UI made using native Jetpack Compose.
Create the iOS application
For the iOS part of the project, you'll make use of SwiftUI to build the user interface and the Model View View-Model pattern.
The Kotlin Multiplatform wizard generates an iOS project that is already connected to the shared module. The Kotlin module is exported with the name specified in the shared/build.gradle.kts
file (baseName = "Shared"
), and imported using a regular import
statement: import Shared
.
Add the dynamic linking flag for SQLDelight
By default, the Kotlin Multiplatform wizard generates projects set up for static linking of iOS frameworks.
To use the native SQLDelight driver on iOS, add the dynamic linker flag that allows Xcode tooling to find the system-provided SQLite binary:
In Android Studio, right-click the
iosApp/iosApp.xcodeproj
folder and select the Open In | Xcode option.In Xcode, double-click the project name to open its settings.
Switch to the Build Settings tab and search for the Other Linker Flags field.
Double-click the field value, click +, and add the
-lsqlite3
string.
Prepare a Koin class for iOS dependency injection
To use Koin classes and functions in Swift code, create a special KoinComponent
class and declare the Koin module for iOS.
In the
shared/src/iosMain/kotlin/
source set, create a file with the namecom/jetbrains/spacetutorial/KoinHelper.kt
(it will appear next to thecache
folder).Add the
KoinHelper
class, which will wrap theSpaceXSDK
class with a lazy Koin injection:package com.jetbrains.spacetutorial import org.koin.core.component.KoinComponent import com.jetbrains.spacetutorial.entity.RocketLaunch import org.koin.core.component.inject class KoinHelper : KoinComponent { private val sdk: SpaceXSDK by inject<SpaceXSDK>() suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { return sdk.getLaunches(forceReload = forceReload) } }Add the
initKoin
function, which you will use in Swift to initialize and start the iOS Koin module:import com.jetbrains.spacetutorial.cache.IOSDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.core.context.startKoin import org.koin.dsl.module fun initKoin() { startKoin { modules(module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = IOSDatabaseDriverFactory(), api = get() ) } }) } }
Now, you can start the Koin module in your iOS app to use the native database driver with the common SpaceXSDK
class.
Implement the UI
First, you'll create a RocketLaunchRow
SwiftUI view for displaying an item from the list. It will be based on the HStack
and VStack
views. There will be extensions on the RocketLaunchRow
structure with useful helpers for displaying the data.
Right-click the
iosApp/iosApp.xcodeproj
directory and choose Open In | Xcode.In your Xcode project, create a new Swift file with the type SwiftUI View and name it
RocketLaunchRow
.Update the
RocketLaunchRow.swift
file with the following code:import SwiftUI import Shared struct RocketLaunchRow: View { var rocketLaunch: RocketLaunch var body: some View { HStack() { VStack(alignment: .leading, spacing: 10.0) { Text("\(rocketLaunch.missionName) - \(String(rocketLaunch.launchYear))").font(.system(size: 18)).bold() Text(launchText).foregroundColor(launchColor) Text("Launch year: \(String(rocketLaunch.launchYear))") Text("\(rocketLaunch.details ?? "")") } Spacer() } } } extension RocketLaunchRow { private var launchText: String { if let isSuccess = rocketLaunch.launchSuccess { return isSuccess.boolValue ? "Successful" : "Unsuccessful" } else { return "No data" } } private var launchColor: Color { if let isSuccess = rocketLaunch.launchSuccess { return isSuccess.boolValue ? Color.green : Color.red } else { return Color.gray } } }The list of launches will be displayed in the
ContentView
view, which is already included in the project.Create an extension to the
ContentView
class with aViewModel
class which will prepare and manage the data. Add the following code to theContentView.swift
file:extension ContentView { enum LoadableLaunches { case loading case result([RocketLaunch]) case error(String) } @MainActor class ViewModel: ObservableObject { @Published var launches = LoadableLaunches.loading } }The view model (
ContentView.ViewModel
) connects with the view (ContentView
) via the Combine framework:The
ContentView.ViewModel
class is declared as anObservableObject
.The
@Published
attribute is used for thelaunches
property, so the view model will emit signals whenever this property changes.
Remove the
ContentView_Previews
structure: you won't need to implement a preview that should be compatible with your view model.Update the body of the
ContentView
class to display the list of launches and add the reload functionality.This is the UI groundwork: you will implement the
loadLaunches
function in the next phase of the tutorial.The
viewModel
property is marked with the@ObservedObject
attribute to subscribe to the view model.
struct ContentView: View { @ObservedObject private(set) var viewModel: ViewModel var body: some View { NavigationView { listView() .navigationBarTitle("SpaceX Launches") .navigationBarItems(trailing: Button("Reload") { self.viewModel.loadLaunches(forceReload: true) }) } } private func listView() -> AnyView { switch viewModel.launches { case .loading: return AnyView(Text("Loading...").multilineTextAlignment(.center)) case .result(let launches): return AnyView(List(launches) { launch in RocketLaunchRow(rocketLaunch: launch) }) case .error(let description): return AnyView(Text(description).multilineTextAlignment(.center)) } } }The
RocketLaunch
class is used as a parameter for initializing theList
view, so it needs to conform to theIdentifiable
protocol. The class already has a property namedid
, so all you should do is add an extension to the bottom ofContentView.swift
:extension RocketLaunch: Identifiable { }
Load the data
To retrieve the data about the rocket launches in the view model, you'll need an instance of the KoinHelper
class from the Multiplatform library. It will allow you to call the SDK function with the correct database driver.
In the
ContentView.swift
file, expand theViewModel
class to include aKoinHelper
object and theloadLaunches
function:extension ContentView { // ... @MainActor class ViewModel: ObservableObject { // ... let helper: KoinHelper = KoinHelper() init() { self.loadLaunches(forceReload: false) } func loadLaunches(forceReload: Bool) { // TODO: retrieve data } } }Call the
KoinHelper.getLaunches()
function (which will proxy the call to theSpaceXSDK
class) and save the result in thelaunches
property:func loadLaunches(forceReload: Bool) { Task { do { self.launches = .loading let launches = try await helper.getLaunches(forceReload: forceReload) self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }When you compile a Kotlin module into an Apple framework, suspending functions can be called using the Swift's
async
/await
mechanism.Since the
getLaunches
function is marked with the@Throws(Exception::class)
annotation in Kotlin, any exceptions that are instances of theException
class or its subclass will be propagated to Swift asNSError
. Therefore, all such exceptions can be caught by theloadLaunches()
function.
Go to the app's entry point, the
iOSApp.swift
file, and initialize the Koin module, the view, and the view model:import SwiftUI import Shared @main struct iOSApp: App { init() { KoinHelperKt.doInitKoin() } var body: some Scene { WindowGroup { ContentView(viewModel: .init()) } } }In Android Studio, switch to the iosApp configuration, choose an emulator, and run it to see the result:
What's next?
This tutorial features some potentially resource-heavy operations, like parsing JSON and making requests to the database in the main thread. To learn about how to write concurrent code and optimize your app, see the Coroutines guide.
You can also check out these additional learning materials: