Sharing UI Code: Jetpack Compose Across Platforms Guide

In modern software development, cross-platform solutions have become increasingly important. Developers aim to maximize code reuse, reduce development time, and ensure a consistent user experience across various platforms such as Android, iOS, web, and desktop. Jetpack Compose, with its declarative UI paradigm and Kotlin’s multiplatform capabilities, makes it easier than ever to share UI code across different platforms. This article explores strategies and best practices for achieving this in Jetpack Compose.

Why Share UI Code Across Platforms?

  • Code Reuse: Write once, deploy everywhere, reducing redundancy.
  • Faster Development: Accelerate development by leveraging common codebases.
  • Consistent UI/UX: Ensure a unified user experience across all platforms.
  • Reduced Maintenance: Maintain a single source of truth for UI components.

Strategies for Sharing UI Code in Jetpack Compose

Jetpack Compose enables sharing UI code primarily through Kotlin Multiplatform (KMP). Here’s a comprehensive look at different strategies to achieve this.

1. Kotlin Multiplatform (KMP) with Compose

KMP allows you to target multiple platforms with a single codebase, making it ideal for sharing UI logic and some UI components.

Setting Up a Multiplatform Project

First, set up a Kotlin Multiplatform project in IntelliJ IDEA or Android Studio. The project structure will typically include:

  • androidApp: Android-specific code.
  • iosApp: iOS-specific code.
  • commonMain: Shared code, including UI components using Jetpack Compose.
Step 1: Create a KMP Project

Use the Kotlin Multiplatform wizard to create a new project. Select the target platforms you want to support (Android, iOS, etc.).

Step 2: Configure Dependencies

In the commonMain source set’s build.gradle.kts, add Compose dependencies. Keep in mind that only a subset of Compose functionalities is available for non-Android targets. Specifically, `org.jetbrains.compose:compose-multiplatform` is used here, instead of `androidx.compose.ui:ui` which is specific for Android. When writing shared UI, use components that come from `org.jetbrains.compose`.


kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(compose.components.core)
                implementation(compose.components.ui)
                implementation(compose.components.material)
            }
        }
        val androidMain by getting {
            dependencies {
                implementation("androidx.appcompat:appcompat:1.6.1")
                implementation("androidx.core:core-ktx:1.12.0")
                implementation(compose.components.toolingPreview)  //Added for preview support in Android Studio
                implementation(compose.components.uiTooling) // Same
            }
        }
        val iosMain by getting {
             dependencies {
                  implementation(compose.components.foundation) // Or appropriate platform implementation
                  implementation(compose.components.ui)
                  implementation(compose.components.material)
            }
        }
    }
}

Note that compose.components.* are variables usually defined within a `compose` extension in the `build.gradle.kts`, referring to various `org.jetbrains.compose` artifacts. These should correspond with the desired version of Compose Multiplatform.

Step 3: Write Shared UI Code

Create composable functions in the commonMain source set.


// commonMain/kotlin/SharedGreeting.kt
import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun SharedGreeting(name: String) {
    Text("Hello, $name! (Shared)")
}
Step 4: Use Shared Composables in Platform-Specific Apps

In both your androidApp and iosApp, you can now use the SharedGreeting composable.

Android (androidApp/src/main/java/com/example/myapp/MainActivity.kt):


package com.example.myapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.example.SharedGreeting
import androidx.compose.material.MaterialTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             MaterialTheme { // You might need to wrap with MaterialTheme depending on your imports and overall app
                SharedGreeting("Android User")
            }
        }
    }
}

iOS (iosApp/iosApp/ContentView.swift using the Compose interop):


import SwiftUI
import Compose

struct ContentView: View {
    var body: some View {
          ComposeUIViewControllerRepresentable {
             SharedGreeting(name: "iOS User")
          }
    }
}

// Boilerplate to allow you to embed composable functions within swift UI Views.
struct ComposeUIViewControllerRepresentable: UIViewControllerRepresentable {
    let content: @MainActor @Composable () -> Void

    init(@MainActor content: @escaping @Composable () -> Void) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIViewController {
        let vc = UIViewController()
        vc.navigationItem.hidesBackButton = true
        return vc
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        uiViewController.view.setContent {
           self.content()
        }
    }
}

2. Abstraction Layers for Platform-Specific UI

Sometimes, the UI needs to be tailored to the specific platform while still sharing the core logic. In such cases, abstract the UI layer and provide platform-specific implementations.

Step 1: Define an Interface

Create an interface that defines the UI components in the commonMain source set.


// commonMain/kotlin/GreetingView.kt
import androidx.compose.runtime.Composable

interface GreetingView {
    @Composable
    fun Display(name: String)
}
Step 2: Provide Platform-Specific Implementations

Implement the interface in the androidMain and iosMain source sets with Compose implementations that are suitable for each platform.

Android (androidApp/src/main/java/com/example/myapp/AndroidGreetingView.kt):


// androidApp/src/androidMain/kotlin/com/example/myapp/AndroidGreetingView.kt
package com.example.myapp

import androidx.compose.material.Text
import androidx.compose.runtime.Composable

class AndroidGreetingView : GreetingView {
    @Composable
    override fun Display(name: String) {
        Text("Android Greeting: $name")
    }
}

iOS (iosApp/iosApp/iOSGreetingView.swift adapting through SwiftUI interoperability ):


import SwiftUI
import Compose

class iOSGreetingView: GreetingView  {

      func Display(name: String) -> () -> Void {  //returns a composable lambda
            return {
               Text("iOS Greeting: (name)")
            }
      }
}
Step 3: Use the Implementation in Your Platform-Specific Code

Now, in your Android and iOS applications, you can use these platform-specific implementations.

3. Sharing Logic with Expect/Actual

When some parts of the UI rely on platform-specific APIs or behaviors, use the expect and actual keywords to abstract these differences.

Step 1: Declare Expected Functionality

In commonMain, declare the expect function:


// commonMain/kotlin/PlatformUtils.kt
expect fun getPlatformName(): String
Step 2: Implement Actual Functionality

Provide the actual implementation in the androidMain and iosMain source sets.

Android (androidApp/src/main/java/com/example/myapp/AndroidPlatformUtils.kt):


// androidApp/src/androidMain/kotlin/com/example/myapp/AndroidPlatformUtils.kt
actual fun getPlatformName(): String = "Android"

iOS (iosApp/iosApp/iOSPlatformUtils.swift bridging to Kotlin):


// iosApp/src/iosMain/kotlin/com/example/myapp/iOSPlatformUtils.kt
actual fun getPlatformName(): String = "iOS"
Step 3: Use the Function in Shared Code

Now, you can use getPlatformName() in your shared UI code:


// commonMain/kotlin/PlatformGreeting.kt
import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun PlatformGreeting() {
    Text("Running on: ${getPlatformName()}")
}

Best Practices for Cross-Platform UI Sharing

  • Use Clean Architecture: Separate UI, domain, and data layers for better maintainability.
  • Avoid Platform-Specific Dependencies in Common Code: Use abstraction or expect/actual for platform-specific code.
  • Test Thoroughly: Test UI on all target platforms to ensure consistency and functionality.
  • Keep UI Logic Separate: Share business logic separately from the UI layer.
  • Use Theme Providers: Employ theme providers to ensure your application looks native on different platforms.

Limitations and Considerations

  • UI Complexity: Sharing highly complex UI may be challenging due to differences in platform UI frameworks.
  • Performance: Optimize UI for each platform to ensure good performance.
  • Third-Party Libraries: Ensure third-party libraries are compatible with all target platforms.

Conclusion

Sharing UI code across platforms with Jetpack Compose is a viable and beneficial strategy for modern application development. By leveraging Kotlin Multiplatform, abstracting platform-specific components, and adhering to best practices, developers can significantly reduce development time, ensure a consistent user experience, and simplify maintenance across multiple platforms.