Kotlin Multiplatform lets you share core business logic while still taking full advantage of native UI components. We explore practical approaches to integrating Kotlin Compose with iOS frameworks like UIKit and SwiftUI.
Mobile developers often face the challenge of balancing consistent business logic with the unique demands of each platform’s user interface. Kotlin Multiplatform (KMP) is a great choice for tackling this, allowing developers to write shared business logic while retaining platform-specific flexibility.
When it comes to integrating UI frameworks like Compose with native iOS components (e.g., SwiftUI or UIKit), KMP offers multiple approaches tailored to various use cases. This article discusses the core expect/actual
pattern – the foundation of KMP’s platform-specific customizations – and presents two additional strategies: using UIViewControllerRepresentable
to integrate SwiftUI and directly accessing iOS native APIs through platform.UIKit
.
The foundation: the expect/actual
pattern in KMP
The expect/actual
pattern is the cornerstone of Kotlin Multiplatform development. It enables developers to define shared abstractions (expect
) and provide platform-specific implementations (actual
). This approach is particularly useful when you want to share as much logic as possible while accommodating platform-specific details, including UI components.
Example:
Shared code: expect
declaration
expect fun showPlatformDialog(message: String)
iOS implementation
actual fun showPlatformDialog(message: String) {
val alert = UIAlertController(
title = "iOS Dialog",
message = message,
preferredStyle = UIAlertControllerStyleAlert
)
alert.addAction(
UIAlertAction.actionWithTitle("OK", UIAlertActionStyleDefault, null)
)
val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
rootViewController?.presentViewController(alert, animated = true, completion = null)
}
Android implementation
actual fun showPlatformDialog(message: String) {
AlertDialog.Builder(context)
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
This pattern ensures:
- Platform independence: Core logic remains in the shared module.
- Flexibility: Platform-specific UI implementations can vary greatly.
- Extensibility: New platforms can be added with minimal disruption.
While powerful, this pattern primarily handles business logic or modular UI components. More specialized use cases, such as embedding SwiftUI views or accessing native APIs directly, will require additional techniques.
1. Direct access to iOS APIs via platform.UIKit
For developers requiring granular control over native iOS functionalities, directly accessing APIs via platform.UIKit
is a powerful alternative. This method uses UIKit directly, allowing you to manipulate UIKit components and system APIs directly in Kotlin.
Example:
Again, we’ll start with the first dialog example.
Shared code: expect
declaration
expect fun showPlatformDialog(message: String)
iOS implementation: creating and displaying a native alert dialog
import platform.UIKit.*
actual fun showPlatformDialog() {
val alert = UIAlertController(
title = "Native Dialog",
message = "This is a native dialog",
preferredStyle = UIAlertControllerStyleAlert
)
alert.addAction(
UIAlertAction.actionWithTitle("OK",
UIAlertActionStyleDefault, null)
)
val rootViewController =
UIApplication.sharedApplication.keyWindow?.rootViewController
rootViewController?.presentViewController(alert, animated = true, completion = null)
}
Integrating in a Composable:
@Composable
fun MyButtonWithNativeDialog() {
Button(onClick = {
showPlatformDialog()
}) {
Text("Show Dialog")
}
}
Pros:
- Provides unrestricted access to native iOS capabilities.
- Ideal for leveraging platform-specific APIs not available in SwiftUI or Compose.
Cons:
- Provides unrestricted access to native iOS capabilities.
- Writing and maintaining platform-specific code can increase complexity. UI consistency between platforms requires additional effort.
2. Integration with UIViewControllerRepresentable
for SwiftUI on iOS
When dealing with existing SwiftUI components, the UIViewControllerRepresentable
approach is invaluable. It wraps a SwiftUI view in a UIViewController
that Kotlin Compose can embed. This is ideal for scenarios where SwiftUI views need to coexist with Compose.
Example:
SwiftUI Code
struct MySwiftUIView: View {
var body: some View {
VStack {
Text("Hello from SwiftUI!")
Button("Click Me") {
print("Button clicked")
}
}
}
}
Creating a UIViewControllerRepresentable
import SwiftUI
import UIKit
struct MySwiftUIViewController: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIHostingController<MySwiftUIView> {
return UIHostingController(rootView: MySwiftUIView())
}
func updateUIViewController(_ uiViewController: UIHostingController<MySwiftUIView>, context: Context) {
// Update logic, if needed
}
}
Embedding the SwiftUI View Controller in Kotlin Compose
@Composable
fun MyComposableView() {
UIKitView(
factory = { MySwiftUIViewController() },
modifier = Modifier.fillMaxSize()
)
}
Pros:
- Provides unrestricted access to native iOS capabilities.
- Reuses SwiftUI components effortlessly within Kotlin Compose. Provides full access to SwiftUI’s advanced features.
Cons:
- Overhead in managing state and synchronization between Compose and SwiftUI.
- Bridging between two UI frameworks may affect performance for complex UIs.
Navigating Kotlin Compose and iOS frameworks
The interoperability between Kotlin Compose and iOS frameworks offers a wide range of possibilities, each with its strengths:
- The
expect/actual
pattern serves as the cornerstone, enabling clean abstractions and cross-platform consistency. - Direct access to iOS APIs offers unmatched control for custom functionality, making it suitable for scenarios requiring precise native behavior.
- SwiftUI integration provides a seamless way to reuse or embed native declarative components, ideal for projects prioritizing iOS-native aesthetics.
Choosing the right approach depends on your project’s goals and constraints. Thanks to Kotlin Multiplatform adaptability, developers are not tied to a single solution but can leverage the best of both Compose and native frameworks to build robust, flexible, and maintainable applications.