Writing iOS code in Kotlin Multiplatform. Part 1 — expect/actual
In previous articles (one, two), I covered the basics of starting with Kotlin Multiplatform. Now, I’d like to share insights on writing iOS-specific code within the framework.
expect/actual
Imagine you have a Payment button intended to trigger Google Pay on Android and Apple Pay on iOS. While the general usage is similar, the button’s UI differs on each platform, and clicking it initiates a distinct flow depending on the operating system.
First, we need to declare an expect
function and provide an actual
implementation for each platform target (Android and iOS).
expect
defines a contract between the common and native layers. With experience, you'll get a sense of how best to design this contract, as actual implementations often differ significantly across platforms.
actual
provides the platform-specific implementation of this contract, enabling access to features unique to each platform (such as Java packages on Android and Core Foundation on iOS).
@Composable
expect fun PaymentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
)
Both Android and iOS will have their own actual
declarations
@Composable
actual fun PaymentButton(
onClick: () -> Unit,
modifier: Modifier
) {
// TODO implement the specific platform logic
}
PaymentButton
usage just like any other Compose function
@Composable
fun PaymentScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize().statusBarsPadding()
) {
Text(
text = "Payment Data",
style = MaterialTheme.typography.headlineLarge
)
Text(
"Total: $34.99",
style = MaterialTheme.typography.titleSmall
)
}
// Use PaymentButton as a ordinar Compose function
PaymentButton(
onClick = {
// TODO implement
},
modifier = Modifier
.navigationBarsPadding()
.widthIn(720.dp)
.padding(32.dp)
.align(Alignment.BottomCenter)
)
}
}
Android layer
Let’s start by creating an Android button. For demonstration purposes, we’ll simplify the implementation by focusing on the button itself, ignoring some required rules.
Add the necessary dependencies:
// libs.versions.toml
compose-pay-button = { module = "com.google.pay.button:compose-pay-button", version = "0.1.3" }
// user-interface/build.gradle
kotlin{
sourceSets {
androidMain.dependencies {
implementation(libs.compose.pay.button)
}
// ...
}
}
Modify PaymentButton.android.kt
to implement the button using Android-specific dependencies.
@Composable
actual fun PaymentButton(
onClick: () -> Unit,
modifier: Modifier
) {
PayButton(
type = ButtonType.Plain,
modifier = modifier.fillMaxWidth(),
theme = if (isSystemInDarkTheme()) ButtonTheme.Light else ButtonTheme.Dark,
onClick = {
// TODO implement click action
},
allowedPaymentMethods = ""
)
}
iOS layer
Next, implement the iOS button in PaymentButton.ios.kt
using UIKitView
, which allows us to render iOS components inside a composable, similar to how AndroidView
works on Android with legacy components.
import platform.PassKit.PKPaymentButtonStyleBlack
import platform.PassKit.PKPaymentButtonStyleWhite
import platform.PassKit.PKPaymentButtonTypePlain
import platform.UIKit.UIControlEventTouchUpInside
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
@Composable
actual fun PaymentButton(
onClick: () -> Unit,
modifier: Modifier
) {
val isDarkMode = isSystemInDarkTheme()
UIKitView(
factory = {
val button = object : PKPaymentButton(
paymentButtonType = PKPaymentButtonTypePlain,
paymentButtonStyle = if (isDarkMode) PKPaymentButtonStyleWhite else PKPaymentButtonStyleBlack
) {
// Object + @ObjC annotation are required to make button clickable
@ObjCAction
fun payAction() {
// TODO implement payment click
}
}
button.setCornerRadius(100.0)
button.addTarget(
target = button,
action = NSSelectorFromString(button::payAction.name),
forControlEvents = UIControlEventTouchUpInside
)
button
},
modifier = modifier
.height(48.dp)
.clip(RoundedCornerShape(32.dp))
)
}
Notice how onClick
works with iOS components: to handle clicks, define a function within object : PKPaymentButton
and annotate it with @ObjCAction
. This function is triggered when UIControlEventTouchUpInside
event occurs as we specify NSSelectorFromString(button::payAction.name)
as the action.
In this section, we explored the simplest way to separate code by platform using expect
/actual
declarations. This is the first challenge you’ll encounter when writing iOS code in Kotlin Multiplatform. However, there may be cases where you need to pass a Context
or other device-specific dependencies. We will discuss how to achieve this in the next chapter.
Check the created code here
https://github.com/sereden/kmp-architecture-demo/commit/36dc52867aea9d90cf7f7e8f00aa3a72243d95f7
If you missed my previous articles on iOS development with Kotlin Multiplatform, be sure to check them out:
- How to start Kotlin Multiplatform with iOS
- From Android To Multiplatform. A Developer’s Roadmap
- Writing iOS code in Kotlin Multiplatform. Part 1 — expect/actual
- Writing iOS code in Kotlin Multiplatform. Part 2 — Context and other Platform-Specific dependencies.
- Writing iOS code in Kotlin Multiplatform. Part 3 — Third-Party Objective-C Libraries
- Writing iOS code in Kotlin Multiplatform. Part 4 — Swift