Android Jetpack Compose multi-platform for Android and IOS

Android Jetpack Compose multi-platform for Android and IOS

JetBrains and external open source contributors have been hard at work developing Compose Multiplatform for several years and recently released an alpha version for iOS. Naturally, we tested its capabilities and decided to experiment by running our Dribbble copy music app on iOS using the framework to see what challenges might arise.

Compose Multiplatform targets desktop and iOS platforms and leverages the functionality of Skia, an open source 2D graphics library widely used on different platforms. Google Chrome, ChromeOS, Mozilla Firefox, JetPack Compose and Flutter widely use Skia as their engine.

Compose Multiplatform Architecture

To understand the Compose Multiplatform approach, we first studied the overview provided by JetBrains, which includes Kotlin Multiplatform Mobile (KMM).

As the diagram shows, the general approach of Kotlin Multiplatform includes:

  1. Write code for iOS-specific APIs such as Bluetooth, CodeData, etc.
  2. Create shared code for business logic.
  3. Create UI on iOS side.

Compose Multiplatform introduces the ability to share UI code, not only business logic code, but also UI code. You can choose to use a native iOS UI framework (UIKit or SwiftUI), or embed your iOS code directly into Compose. We want to see how our complex native UI on Android works on iOS, so we choose to keep the native iOS UI code to a minimum. Currently, you can only write platform-specific APIs using Swift code, while for platform-specific UI, you can use Kotlin and Jetpack Compose to share all other code with Android apps.

As shown, the general approach of Kotlin Multiplatform includes:

  1. Write code specifically targeting iOS APIs such as Bluetooth and CodeData.
  2. Create shared business logic code written in Kotlin.
  3. Create UI on iOS side.

Compose Multiplatform extends the functionality of code sharing, now you can share not only business logic code, but also UI code. You can still create your UI with SwiftUI, or embed UIKit directly into Compose, as we'll discuss below. With this new way of developing, you only need to use Swift code for platform-specific APIs and UIs, while sharing all other code with your Android app using Kotlin and Jetpack Compose. Now, let's dive into the preparations you need to get started.

Prerequisites to run on iOS

The best place to get iOS setup instructions is the official documentation itself. To summarize, here's what you need to get started:

  • Mac computer
  • Xcode
  • Android Studio
  • Kotlin Multiplatform Mobile plugin
  • CocoaPods dependency manager

Additionally, a template is available in the JetBrains repository that can help with multiple Gradle setups.

https://github.com/JetBrains/compose-multiplatform-ios-android-template/#readme

project structure

With the base project set up, you'll see three main directories:

  • androidApp
  • iosApp
  • shared

androidApp and shared are modules because they are related to Android and built using build.gradle. iosApp is the directory of the actual iOS application, which you can open through Xcode. The androidApp module is just the entry point for an Android application. The following code should be familiar to anyone who has ever developed for Android.

class MainActivity : AppCompatActivity() {
    
    
    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContent {
    
    
            MainView()
        }
    }
}

iosApp is the entry point for an iOS application, and contains some boilerplate SwiftUI code:

import SwiftUI

@main
struct iOSApp: App {
    
    
 var body: some Scene {
    
    
  WindowGroup {
    
    
   ContentView()
  }
 }
}

Since this is the entry point, you should implement top-level changes here - for example, we added ignoresSafeAreamodifiers to make the application fullscreen:

struct ComposeView: UIViewControllerRepresentable {
    
    
    func makeUIViewController(context: Context) -> UIViewController {
    
    
        return Main_iosKt.MainViewController()
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    
    }
}

struct ContentView: View {
    
    
    var body: some View {
    
    
        ComposeView()
            .ignoresSafeArea(.all)
    }
}

The above code can already run your Android app on iOS. Here, your ComposeUIViewControlleris wrapped in a UIKit's UIViewControllerand presented to the user. in a Kotlin file named , which MainViewController()contains the code for the Compose application.main.ios.ktApp()

fun MainViewController() = ComposeUIViewController {
    
     App()}

Here is another example provided by JetBrains.

https://github.com/JetBrains/compose-multiplatform/tree/master/examples/chat

If you need some platform-specific functionality, you can use UIKitViewUIKit embedded in Compose code. Below is an example of a map view from JetBrains. When using UIKit, it's very similar to using it in Compose AndroidView, if you're already familiar with the concept.

https://github.com/JetBrains/compose-multiplatform/blob/ea310cede5f08f7960957369247a6575f7bc5392/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt#L7

The shared module is the most important of these three modules. This Kotlin module essentially contains the shared logic implemented by Android and iOS, facilitating the use of the same code base on both platforms. Inside the shared module, you'll find three directories, each with its own purpose: commonMain, androidMain, and iosMain. This is where it gets confusing - actually the code that is actually shared is in commonMainthe directory. The other two directories are for writing platform-specific Kotlin code that will behave or look differently on Android or iOS. This is achieved by commonMainwriting in code expect funand using it in the appropriate platform directory .actual Fun

Migration

When we started the migration, we were sure we'd run into some issues that needed specific fixes. Although the app we chose to migrate was logically very simple (basically just UI, animations, and transitions), as expected, we ran into a fair amount of difficulty. Below are some issues you may encounter during the migration process.

Resource

The first thing we have to deal with is resource usage. There are no dynamically generated R classes, this is only for Android. Instead, you need to place your resources in a resources directory and specify the path as a string. Here is an example of an image:

import org.jetbrains.compose.resources.painterResource

Image(
    painter = painterResource(“image.webp”),
    contentDescription = "",
)

When resources are implemented in this way, runtime crashes may occur if resource names are incorrect, rather than compile-time crashes.

Also, if you reference Android resources in XML files, you also need to get rid of the link to the Android platform.

<vector xmlns:android="http://schemas.android.com/apk/res/android" 
    android:width="24dp"
    android:height="24dp" 
-   android:tint="?attr/colorControlNormal"     
+   android:tint="#000000"
    android:viewportWidth="24"
    android:viewportHeight="24">
-   <path android:fillColor="@android:color/white"
+   <path android:fillColor="#000000"
        android:pathData="M9.31,6.71c-0.39,0.39 -0.39,1.02 0,1.41L13.19,12l-3.88" />
</vector>

Font

At the time of writing, there is no way in Compose Multiplatform to use the standard font loading techniques commonly used on iOS and Android. As far as we can see, Jetbrains recommends using byte arrays to load fonts, as shown in the iOS code below:

private val cache: MutableMap<String, Font> = mutableMapOf()

@OptIn(ExperimentalResourceApi::class)
@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {
    
    
    return cache.getOrPut(res) {
    
    
        val byteArray = runBlocking {
    
    
            resource("font/$res.ttf").readBytes()
        }
        androidx.compose.ui.text.platform.Font(res, byteArray, weight, style)
    }
}

However, we don't like the asynchronous approach and the use of blocking the main thread during execution runBlocking. So on Android we decided to take a more common approach, using integer identifiers:

@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {
    
    
    val context = LocalContext.current
    val id = context.resources.getIdentifier(res, "font", context.packageName)
    return Font(id, weight, style)
}

Create a Font object when using:

object Fonts {
    
    
    @Composable
    fun abrilFontFamily() = FontFamily(
        font(
            "Abril",
            "abril_fatface",
            FontWeight.Normal,
            FontStyle.Normal
        ),
    )
}

Replace Java with Kotlin


It is not possible to use Java code in Compose Multiplatform because it uses Kotlin compiler plugin. Therefore, we need to rewrite the part that uses Java code. For example, in our app, a time formatter converts the time of a music track from seconds to the more convenient minutes format. We had to drop using java.util.concurrent.TimeUnit, but it turned out to be a good thing, because it gave us the opportunity to refactor the code and write it more elegantly.

fun format(playbackTimeSeconds: Long): String {
    
    
-   val minutes = TimeUnit.SECONDS.toMinutes(playbackTimeSeconds)
+   val minutes = playbackTimeSeconds / 60
-   val seconds = if (playbackTimeSeconds < 60) {
    
    
-       playbackTimeSeconds
-   } else {
    
    
-       (playbackTimeSeconds - TimeUnit.MINUTES.toSeconds(minutes))
-   }
+   val seconds = playbackTimeSeconds % 60
    return buildString {
    
    
        if (minutes < 10) append(0)
        append(minutes)
        append(":")
        if (seconds < 10) append(0)
        append(seconds)
    }
}

Native Canvas

Sometimes, we use the Android Native canvas to create drawings. However, in Compose Multiplatform, we cannot access the Android native canvas in common code, so the code must be adjusted accordingly. For example, we have an animated title text that relies on native canvas measureText(letter)functions to animate verbatim. We had to find an alternative for this functionality, so we rewrote it using the Compose canvas, and used TextMeasurerinstead Paint.measureText(letter).

fun format(playbackTimeSeconds: Long): String {
    
    
-   val minutes = TimeUnit.SECONDS.toMinutes(playbackTimeSeconds)
+   val minutes = playbackTimeSeconds / 60
-   val seconds = if (playbackTimeSeconds < 60) {
    
    
-       playbackTimeSeconds
-   } else {
    
    
-       (playbackTimeSeconds - TimeUnit.MINUTES.toSeconds(minutes))
-   }
+   val seconds = playbackTimeSeconds % 60
    return buildString {
    
    
        if (minutes < 10) append(0)
        append(minutes)
        append(":")
        if (seconds < 10) append(0)
        append(seconds)
    }
}


The drawText method also depends on the native canvas, so it must be overridden:

Gestures

On Android, BackHandleralways available - it handles the back gesture or the back button press, depending on the navigation modes available to the device. But this approach doesn't work in Compose Multiplatform as BackHandlerpart of Android source set. Instead, let's use expect fun:

@Composable
expect fun BackHandler(isEnabled: Boolean, onBack: ()-> Unit)

//Android implementation
@Composable
actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) {
    
    
  BackHandler(isEnabled, onBack)
}

In iOS, there are many different ways one can come up with to achieve the desired result. For example, you can write your own back gesture in Compose, or if you have multiple screens in your app, you can wrap each screen in a separate UIViewControllerand use the native iOS navigator that includes default gestures UINavigationController.

We chose an implementation that handles the back gesture on the iOS side without wrapping separate screens in corresponding controllers (since the transitions between views in our app are highly customized). This is a great example of how to link the two languages ​​together. First, we added a native iOS SwipeGestureViewControllerto detect gestures and added handlers for gesture events. The full iOS implementation can be seen here.

https://github.com/exyte/ComposeMultiplatformDribbbleAudio/blob/main/iosApp/iosApp/ContentView.swift

struct SwipeGestureViewController: UIViewControllerRepresentable {
    
    
    var onSwipe: () -> Void
    
    func makeUIViewController(context: Context) -> UIViewController {
    
    
        let viewController = Main_iosKt.MainViewController()
        let containerController = ContainerViewController(child: viewController) {
    
    
            context.coordinator.startPoint = $0
        }
        
        let swipeGestureRecognizer = UISwipeGestureRecognizer(
            target:
                context.coordinator, action: #selector(Coordinator.handleSwipe)
        )
        swipeGestureRecognizer.direction = .right
        swipeGestureRecognizer.numberOfTouchesRequired = 1
        containerController.view.addGestureRecognizer(swipeGestureRecognizer)
        return containerController
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    
    }
    
    func makeCoordinator() -> Coordinator {
    
    
        Coordinator(onSwipe: onSwipe)
    }
    
    class Coordinator: NSObject, UIGestureRecognizerDelegate {
    
    
        var onSwipe: () -> Void
        var startPoint: CGPoint?
        
        init(onSwipe: @escaping () -> Void) {
    
    
            self.onSwipe = onSwipe
        }
        
        @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
    
    
            if gesture.state == .ended, let startPoint = startPoint, startPoint.x < 50 {
    
    
                onSwipe()
            }
        }
        
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    
    
            true
        }
    }
}

Then, create a corresponding function in the main.ios.kt file:

fun onBackGesture() {
    
    
    store.send(Action.OnBackPressed)
}

We can call this function like this in Swift:

public func onBackGesture() {
    
    
    Main_iosKt.onBackGesture()
}

We implemented a repository that collects actions.

interface Store {
    
    
    fun send(action: Action)
    val events: SharedFlow<Action>
}

fun CoroutineScope.createStore(): Store {
    
    
    val events = MutableSharedFlow<Action>()

    return object : Store {
    
    
        override fun send(action: Action) {
    
    
            launch {
    
    
                events.emit(action)
            }
        }
        override val events: SharedFlow<Action> = events.asSharedFlow()
    }
}

This repository uses store.events.collectmethods to accumulate actions.

@Composable
actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) {
    
    
    LaunchedEffect(isEnabled) {
    
    
        store.events.collect {
    
    
            if(isEnabled) {
    
    
                onBack()
            }
        }
    }
}

This helps resolve differences in gesture handling on the two platforms, making back navigation a native and intuitive experience for iOS apps.

least bugs

In some cases, you may encounter some minor issues, such as on the iOS platform, when tapped, the item scrolls up to become visible. You can compare the desired behavior (Android) with the wrong iOS behavior below:

it's because Modifier.clickable gives the item focus when it is clicked, which triggers the bringIntoViewscrolling mechanism. Focus management is different on Android and iOS, causing this different behavior. We .focusProperties { canFocus = false }work around this by adding modifiers to items.

in conclusion

Compose Multiplatform is the next stage of multiplatform development for the Kotlin language after KMM. This technique provides more opportunities for code sharing, not only limited to business logic, but also UI components. While it's possible to use Compose and SwiftUI together in a multi-platform application, it doesn't look very intuitive at the moment.

You should consider whether your application has the business logic, UI, or functional capabilities to share code from multiple platforms. If your application requires many platform-specific features, KMM and Compose Multiplatform may not be the best choice. This repository contains the full implementation. You can also look at existing libraries to get a better idea of ​​what current KMM does.

https://github.com/terrakok/kmm-awesome

As for us, we are impressed with Compose Multiplatform and think it will be usable in our real projects once the stable version is released. It's best suited for UI-heavy apps without a ton of hardware-specific features. It may be a viable alternative to Flutter and native development, but time will tell. In the meantime, we'll continue to focus on native development - check out our iOS and Android articles!

Guess you like

Origin blog.csdn.net/u011897062/article/details/131658779