Make your Android application work on iOS – tutorial
Learn how to make your existing Android application cross-platform so that it works both on Android and iOS. You'll be able to write code and test it for both Android and iOS only once, in one place.
This tutorial uses a sample Android application with a single screen for entering a username and password. The credentials are validated and saved to an in-memory database.
Prepare an environment for development
Install all the necessary tools and update them to the latest versions.
In Android Studio, create a new project from version control:
https://github.com/Kotlin/kmp-integration-sampleSwitch to the Project view.
Make your code cross-platform
To make your application work on iOS, you'll first make your code cross-platform, and then you'll reuse your cross-platform code in a new iOS application.
To make your code cross-platform:
Decide what code to make cross-platform
Decide which code of your Android application is better to share for iOS and which to keep native. A simple rule is: share what you want to reuse as much as possible. The business logic is often the same for both Android and iOS, so it's a great candidate for reuse.
In your sample Android application, the business logic is stored in the package com.jetbrains.simplelogin.androidapp.data
. Your future iOS application will use the same logic, so you should make it cross-platform, as well.
Create a shared module for cross-platform code
The cross-platform code that is used for both iOS and Android will be stored in a shared module. The Kotlin Multiplatform plugin for Android Studio provides a wizard for creating such modules.
Create a shared module and connect it to both the existing Android application and your future iOS application:
In Android Studio settings, select the Advanced Settings section and turn on the Enable experimental Multiplatform IDE features option.
Restart Android Studio for the changes to take effect.
Select File | New | New Module from the main menu.
In the list of templates, select Java or Kotlin Library. Enter the library name
shared
and the package namecom.jetbrains.simplelogin.shared
.Click Finish. The wizard creates a base module that you'll expand into a Kotlin Multiplatform module.
In the root
build.gradle.kts
file, replace the contents with the following code to properly apply Gradle plugins:plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidLibrary) apply false }In the
shared/build.gradle.kts
file, define the necessary KMP targets. To do that, replace the contents of the file with the following code:import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) } kotlin { androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "Shared" isStatic = true } } sourceSets { commonMain.dependencies { // Contains your multiplatform dependencies } } } android { namespace = "com.jetbrains.simplelogin.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } }Sync the Gradle files as suggested by the IDE or using the File | Sync Project with Gradle Files menu item.
In the
shared/src
directory, createandroidMain/kotlin
,commonMain/kotlin
, andiosMain/kotlin
directories.In the
shared/src
directory, delete themain
directory.Inside those directories, create packages and files to replicate the following structure:
Add code to the files that you created:
For
commonMain/Platform.kt
:package com.jetbrains.simplelogin.shared interface Platform { val name: String } expect fun getPlatform(): PlatformFor
commonMain/Greeting.kt
:package com.jetbrains.simplelogin.shared class Greeting { private val platform = getPlatform() fun greet(): String { return "Hello, ${platform.name}!" } }For
androidMain/Platform.android.kt
:package com.jetbrains.simplelogin.shared import android.os.Build class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" } actual fun getPlatform(): Platform = AndroidPlatform()For
iosMain/Platform.ios.kt
:package com.jetbrains.simplelogin.shared import platform.UIKit.UIDevice class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion } actual fun getPlatform(): Platform = IOSPlatform()
In the
app/build.gradle.kts
file, set theandroid.defaultConfig.minSdk
value to 24.Sync the Gradle files as suggested by the IDE or using the File | Sync Project with Gradle Files menu item.
You can find the resulting state of the project in the shared_module branch of the GitHub repository.
If you want to better understand the layout of the resulting project, see basics of Kotlin Multiplatform project structure.
Add a dependency on the shared module to your Android application
To use cross-platform code in your Android application, connect the shared module to it, move the business logic code there, and make this code cross-platform.
Add a dependency on the shared module to the
app/build.gradle.kts
file:dependencies { // ... implementation(project(":shared")) }Sync the Gradle files as suggested by the IDE or using the File | Sync Project with Gradle Files menu item.
In the
app/src/main/java/
directory, open theLoginActivity.kt
file in thecom.jetbrains.simplelogin.androidapp.ui.login
package.To make sure that the shared module is successfully connected to your application, dump the
greet()
function result to the log by adding a line to theonCreate()
method:override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.i("Login Activity", "Hello from shared module: " + (Greeting().greet())) // ... }Follow Android Studio's suggestions to import missing classes.
In the toolbar, select
app
from the dropdown and click Debug .On the Logcat tab, search for
Hello
in the log, and you'll find the greeting from the shared module.
Make the business logic cross-platform
You can now extract the business logic code to the Kotlin Multiplatform shared module and make it platform-independent. This is necessary for reusing the code for both Android and iOS.
Move the business logic code
com.jetbrains.simplelogin.androidapp.data
from theapp
directory to thecom.jetbrains.simplelogin.shared
package in theshared/src/commonMain
directory.When Android Studio asks what you'd like to do, select to move the package, and then approve the refactoring.
Ignore all warnings about platform-dependent code and click Continue.
Remove Android-specific code by replacing it with cross-platform Kotlin code or connecting to Android-specific APIs using expected and actual declarations. See the following sections for details:
Replace Android-specific code with cross-platform code
To make your code work well on both Android and iOS, replace all JVM dependencies with Kotlin dependencies in the moved data
directory wherever possible.
In the
LoginDataSource
class, replaceIOException
in thelogin()
function withRuntimeException
.IOException
is not available in Kotlin/JVM.// Before return Result.Error(IOException("Error logging in", e))// After return Result.Error(RuntimeException("Error logging in", e))Remove the import directive for
IOException
as well:import java.io.IOExceptionIn the
LoginDataValidator
class, replace thePatterns
class from theandroid.utils
package with a Kotlin regular expression matching the pattern for email validation:// Before private fun isEmailValid(email: String) = Patterns.EMAIL_ADDRESS.matcher(email).matches()// After private fun isEmailValid(email: String) = emailRegex.matches(email) companion object { private val emailRegex = ("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" + "\\@" + "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + "(" + "\\." + "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" + ")+").toRegex() }And remove the import directive for the
Patterns
class:import android.util.Patterns
Connect to platform-specific APIs from the cross-platform code
In the LoginDataSource
class, a universally unique identifier (UUID) for fakeUser
is generated using the java.util.UUID
class, which is not available for iOS.
Since the Kotlin standard library doesn't provide functionality for generating UUIDs, you still need to use platform-specific functionality for this case.
Provide the expect
declaration for the randomUUID()
function in the shared code and its actual
implementations for each platform – Android and iOS – in the corresponding source sets. You can learn more about connecting to platform-specific APIs.
Remove the
java.util.UUID
class from the common code:val fakeUser = LoggedInUser(randomUUID(), "Jane Doe")Create the
Utils.kt
file in thecom.jetbrains.simplelogin.shared
package of theshared/src/commonMain
directory and provide theexpect
declaration:package com.jetbrains.simplelogin.shared expect fun randomUUID(): StringCreate the
Utils.android.kt
file in thecom.jetbrains.simplelogin.shared
package of theshared/src/androidMain
directory and provide theactual
implementation forrandomUUID()
in Android:package com.jetbrains.simplelogin.shared import java.util.* actual fun randomUUID() = UUID.randomUUID().toString()Create the
Utils.ios.kt
file in thecom.jetbrains.simplelogin.shared
of theshared/src/iosMain
directory and provide theactual
implementation forrandomUUID()
in iOS:package com.jetbrains.simplelogin.shared import platform.Foundation.NSUUID actual fun randomUUID(): String = NSUUID().UUIDString()All that is left to do is to explicitly import
randomUUID
in theLoginDataSource.kt
file of theshared/src/commonMain
directory:import com.jetbrains.simplelogin.shared.randomUUIDNow, Kotlin will use different platform-specific implementations of UUID for Android and iOS.
Run your cross-platform application on Android
Run your cross-platform application for Android to make sure it works.
Make your cross-platform application work on iOS
Once you've made your Android application cross-platform, you can create an iOS application and reuse the shared business logic in it.
Create an iOS project in Xcode
In Xcode, click File | New | Project.
Select a template for an iOS app and click Next.
As the product name, specify simpleLoginIOS and click Next.
As the location for your project, select the directory that stores your cross-platform application, for example,
kmp-integration-sample
.
In Android Studio, you'll get the following structure:
You can rename the simpleLoginIOS
directory to iosApp
for consistency with other top-level directories of your cross-platform project. To do that, close Xcode and then rename the simpleLoginIOS
directory to iosApp
. If you rename the folder with Xcode open, you'll get a warning and may corrupt your project.
Connect the framework to your iOS project
Once you have the framework, you can connect it to your iOS project manually.
Connect your framework to the iOS project manually:
In Xcode, open the iOS project settings by double-clicking the project name.
On the Build Phases tab of the project settings, click the + and add New Run Script Phase.
Add the following script:
cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcodeMove the Run Script phase before the Compile Sources phase.
On the Build Settings tab, disable the User Script Sandboxing under Build Options:
Build the project in Xcode. If everything is set up correctly, the project will build successfully.
Use the shared module from Swift
In Xcode, open the
ContentView.swift
file and import theshared
module:import sharedTo check that it is properly connected, use the
greet()
function from the shared module of your cross-platform app:import SwiftUI import shared struct ContentView: View { var body: some View { Text(Greeting().greet()) .padding() } }Run the app from Xcode to see the result:
In the
ContentView.swift
file, write code for using data from the shared module and rendering the application UI:import SwiftUI import shared struct ContentView: View { @State private var username: String = "" @State private var password: String = "" @ObservedObject var viewModel: ContentView.ViewModel var body: some View { VStack(spacing: 15.0) { ValidatedTextField(titleKey: "Username", secured: false, text: $username, errorMessage: viewModel.formState.usernameError, onChange: { viewModel.loginDataChanged(username: username, password: password) }) ValidatedTextField(titleKey: "Password", secured: true, text: $password, errorMessage: viewModel.formState.passwordError, onChange: { viewModel.loginDataChanged(username: username, password: password) }) Button("Login") { viewModel.login(username: username, password: password) }.disabled(!viewModel.formState.isDataValid || (username.isEmpty && password.isEmpty)) } .padding(.all) } } struct ValidatedTextField: View { let titleKey: String let secured: Bool @Binding var text: String let errorMessage: String? let onChange: () -> () @ViewBuilder var textField: some View { if secured { SecureField(titleKey, text: $text) } else { TextField(titleKey, text: $text) } } var body: some View { ZStack { textField .textFieldStyle(RoundedBorderTextFieldStyle()) .autocapitalization(.none) .onChange(of: text) { _ in onChange() } if let errorMessage = errorMessage { HStack { Spacer() FieldTextErrorHint(error: errorMessage) }.padding(.horizontal, 5) } } } } struct FieldTextErrorHint: View { let error: String @State private var showingAlert = false var body: some View { Button(action: { self.showingAlert = true }) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.red) } .alert(isPresented: $showingAlert) { Alert(title: Text("Error"), message: Text(error), dismissButton: .default(Text("Got it!"))) } } } extension ContentView { struct LoginFormState { let usernameError: String? let passwordError: String? var isDataValid: Bool { get { return usernameError == nil && passwordError == nil } } } class ViewModel: ObservableObject { @Published var formState = LoginFormState(usernameError: nil, passwordError: nil) let loginValidator: LoginDataValidator let loginRepository: LoginRepository init(loginRepository: LoginRepository, loginValidator: LoginDataValidator) { self.loginRepository = loginRepository self.loginValidator = loginValidator } func login(username: String, password: String) { if let result = loginRepository.login(username: username, password: password) as? ResultSuccess { print("Successful login. Welcome, \(result.data.displayName)") } else { print("Error while logging in") } } func loginDataChanged(username: String, password: String) { formState = LoginFormState( usernameError: (loginValidator.checkUsername(username: username) as? LoginDataValidator.ResultError)?.message, passwordError: (loginValidator.checkPassword(password: password) as? LoginDataValidator.ResultError)?.message) } } }In
simpleLoginIOSApp.swift
, import theshared
module and specify the arguments for theContentView()
function:import SwiftUI import shared @main struct SimpleLoginIOSApp: App { var body: some Scene { WindowGroup { ContentView(viewModel: .init(loginRepository: LoginRepository(dataSource: LoginDataSource()), loginValidator: LoginDataValidator())) } } }Run the Xcode project to see that the iOS app shows the login form. Enter "Jane" for the username and "password" for the password. The app validates the input using the shared code:
Enjoy the results – update the logic only once
Now your application is cross-platform. You can update the business logic in one place and see results on both Android and iOS.
In Android Studio, change the validation logic for a user's password: "password" shouldn't be a valid option. To do that, update the
checkPassword()
function of theLoginDataValidator
class:package com.jetbrains.simplelogin.shared.data class LoginDataValidator { //... fun checkPassword(password: String): Result { return when { password.length < 5 -> Result.Error("Password must be >5 characters") password.lowercase() == "password" -> Result.Error("Password shouldn't be \"password\"") else -> Result.Success } } //... }In Android Studio, add a run configuration for the iOS app:
Select Run | Edit configurations in the main menu.
To add a new configuration, click the plus sign and choose iOS Application.
Name the configuration "SimpleLoginIOS".
In the Xcode project file field, select the location of the
simpleLoginIOS.xcodeproj
file.Choose a simulation environment in the Execution target list and click OK.
Run both the iOS and Android applications from Android Studio to see the changes:
You can review the final code for this tutorial.
What else to share?
You've shared the business logic of your application, but you can also decide to share other layers of your application. For example, the ViewModel
class code is almost the same for Android and iOS applications, and you can share it if your mobile applications should have the same presentation layer.
What's next?
Once you've made your Android application cross-platform, you can move on and:
You can also check out community resources: