Four platforms – one code. What is Compose Multiplatform?

image

Developers have long dreamed of being able to write cross-platform code – one that would run and work the same way on any operating system of any architecture. Today, the principle of “Write once, run anywhere”, once thundered in connection with the advent of the Java language, is difficult to surprise anyone. And yet there is a niche in which there is not much cross-platform technology: this is UI development.

It would not be an exaggeration to say that today there are only two UI frameworks that allow you to run the same UI on different platforms and are widely represented on the market: React Native and Flutter. It would seem, what more could you want? Two technologies at once provide the ability to fumble UI features between platforms and do an excellent job of it. But this article is not about them, but about their younger brother, a convenient and powerful tool for mobile and desktop development – Compose Multiplatform.

image

Its basis was Jetpack Compose, the first declarative UI framework for Android. However, thanks to Jetbrains and Kotlin Multiplatform, at the moment it can be run almost anywhere and on anything: Android, iOS, Windows, Linux, MacOS and in the browser (some DIY enthusiasts also reuse the code on WearOS).

There will be no traditional bickering about which framework is better, but let me share one personal impression: after trying React and then dipping into Jetpack Compose, I was left with a bunch of questions about the first and delighted with the simplicity and intuitiveness of the second. When you develop interfaces using Compose, one thought is spinning in your head: not every senior can understand the reactivity of React, but any student can understand the principle of Compose.

image

It is clear that these two frameworks are distinguished by their “target audience”: web development frontends come to React Native, and Android developers who do not want to leave their comfort zone and, without learning new languages ​​and technologies, “reach out” to other platforms. But here’s a look from the outside: at the time of my acquaintance with both technologies, I was equally far from both declarative web development and android. Maybe this article will be an incentive to get acquainted with this new technology for you and get the same buzz from it that I get every day. As for Flutter, I will refrain from commenting, because I have not tried it myself yet.

Today we will try to understand whether it is easy to transfer code written only for android on pure Jetpack Compose to other platforms. (Spoiler: not easy, but very easy.) We will write a simple but working messenger prototype that can be run as a desktop application, a mobile application on Android and iOS, and also in a browser. The application code can be found Here.

To start a new project on Compose Multiplatform, use template on Github.

image

The initial setup provided by the template already contains a very simple Hello-world interface. The project is already divided into modules: androidApp, iosApp, desktopApp and shared (we will add more jsApp to them, but more on that later). Modules, as you might guess, are the target platforms of the application. Inside the shared module, each of the platforms has its own sourceset (sourceset, a set of source code files). All sourcesets have a common part – commonMain, this is a code that is 100% reused.

image

Why such a layered structure? It is possible to build a project on the same sources, but then the advantages of the multi-module structure of the Gradle project will be lost, which I will not expand on here, since they are enough obvious.

Let’s look at the modules of each of the targets, right in alphabetical order. In the module androidApp contains an indispensable for this target AndroidManifest.xml And MainActivity.kt. By and large, this Activity – just an entry point for the whole interface on Android:

package com.myapplication

import MainView
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity

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

        setContent {
            Application()
        }
    }
}

Application – normal Composable a function that is contained in a module shared in sourceset commonMain and is called in the same way in all targets.

IN desktopApp exactly the same function is called, but wrapped in Window. This simple method creates a Swing window and puts our interface in it.

fun main() = application {
    Window(
        title = "ComposeConnect",
        onCloseRequest = ::exitApplication
    ) {
        Application()
    }
}

By the way, in general, the entire interface in the Compose Multiplatform desktop target under the hood has bindings to Swing or AWT, so you can use many methods from these libraries, for example, a file manager or saving graphic resources to disk using awt.Image.

Let’s move on to one of the most interesting parts – the iOS target. If so far all entry points to the application have been written in Kotlin / JVM, then in the module iosApp there is not a single line on Kotlin, but there is a Swift project that refers to the module sharedor rather, its sourceset iosMain:

import UIKit
import SwiftUI
import shared

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        Main_iosKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

struct ContentView: View {
    var body: some View {
        ComposeView()
                .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
    }
}

If above Kotlin Multiplatform showed itself in conjunction with Java, then its other cool feature is obvious here – Swift-interop. Function MainViewController from a file main.ios.kt we call directly in Swift:

fun MainViewController() = ComposeUIViewController { Application() }

Finally, a separate discussion requires the transfer of our application to the browser. It is not supported in the official template from Jetbrains, with the proviso that the Compose Multiplatform js target is in the experimental development stage. But we will still add it to make sure that our application works in all environments.

To do this, in Intellij Idea, create a new module through File -> Project structure -> New module and select Compose Multiplatform. Further Single platform and Web. The newly created module needs to cut off all the “excess”, leaving only build.gadle.kts script. To include a module in the main project, add the appropriate include(project(":jsApp")) V settings.gradle.kts root project.

image

IN build.gradle.kts module shared you need to add the appropriate compilation plugin and sourceset:

kotlin {
    js(IR) {
        browser()
        binaries.executable()
    }
    ...
    sourceSets {
      ...
        val jsMain by getting {}
    }
}

After updating the gradle, let’s take a look at the module itself jsApp. How important it was for the Android target AndroidManifest.xmlso it will be important for the web index.html, which is located in our module’s resources folder. It’s worth mentioning right away that in the browser, the application will be drawn not using the DOM tree, but on a single <canvas> (yes, just like in Flutter). Therefore our index.html will look like this:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>ComposeConnect</title>
        <script src="https://habr.com/ru/companies/timeweb/articles/734818/skiko.js"></script>
    </head>
    <body>
    <div id="root">
        <canvas id="ComposeTarget"></canvas>
    </div>
    <script src="https://habr.com/ru/companies/timeweb/articles/734818/jsApp.js"></script>
    </body>
</html>

The most important parts are

1) skiko.js (skiko, aka Skia for Kotlin – a graphic library that runs the entire Compose Multiplatform, implements low-level bindings to Skia itself for Kotlin on different platforms),

2) <canvas id="ComposeTarget"></canvas>

3) jsApp.js (our application transpiled to JavaScript).

Now in Main.kt Let’s write only 5 lines:

fun main() {
    onWasmReady {
        Window { Application() }
    }
}

This is enough to port the interface to the browser.

The only thing left is to write the application itself! I opted for the messenger and took as a basis JetChat – an exemplary application from Google on pure Jetpack Compose. The most important components of an application are Scaffoldwhich functions as a navigation bar, ConversationContentin which the chat room itself is implemented, and ProfileScreenwhich is the profile view screen of the selected user.

First, let’s create a class MainViewModelwhich plays the same role as ViewModel in android. Unfortunately, Compose Multiplatform does not yet provide its own class ViewModel out of the box, for its cross-platform implementation, the Decompose library is usually used. I plan to do this in the future, but for now let’s try to get by with a simple class with the necessary StateFlow. IN Application just initialize the class:

@Composable
fun Application() {
    val viewModel = remember { MainViewModel() }
    ThemeWrapper(viewModel)
}

The class itself MainViewModel let’s write down a number of objects monitored by the interface StateFlow:

@Stable
class MainViewModel {
    private val _conversationUiState: MutableStateFlow<ConversationUiState> = MutableStateFlow(exampleUiState.getValue("composers"))
    val conversationUiState: StateFlow<ConversationUiState> = _conversationUiState

    private val _selectedUserProfile: MutableStateFlow<ProfileScreenState?> = MutableStateFlow(null)
    val selectedUserProfile: StateFlow<ProfileScreenState?> = _selectedUserProfile

    private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
    val themeMode: StateFlow<ThemeMode> = _themeMode

    private val _drawerShouldBeOpened: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val drawerShouldBeOpened: StateFlow<Boolean> = _drawerShouldBeOpened

    fun setCurrentConversation(title: String) {
        _conversationUiState.value = exampleUiState.getValue(title)
    }

    fun setCurrentAccount(userId: String) {
        _selectedUserProfile.value = exampleAccountsState.getValue(userId)
    }

    fun resetOpenDrawerAction() {
        _drawerShouldBeOpened.value = false
    }

    fun switchTheme(theme: ThemeMode) {
        _themeMode.value = theme
    }

    fun sendMessage(message: Message) {
        _conversationUiState.value.addMessage(message)
    }
}

In this class ConversationUiState – a small utility class that conveniently packs all the data we need to display a separate chat room:

class ConversationUiState(
    val channelName: String,
    val channelMembers: Int,
    initialMessages: List<Message>,
) {
    private val _messages: MutableList<Message> = initialMessages.toMinitialMessages.toMutableList()
    val messages: List<Message> = _messages

    fun addMessage(msg: Message) {
        _messages.add(0, msg) // Add to the beginning of the list
    }
}

To place the application’s top bar and new message input field on top of the message list itself, use Box:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationContent(
    viewModel: MainViewModel,
    scrollState: LazyListState,
    scope: CoroutineScope,
    onNavIconPressed: () -> Unit,
) {
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
    val messagesState by viewModel.conversationUiState.collectAsState()
    Box(modifier = Modifier.fillMaxSize()) {
        Messages(messagesState, scrollState)
        Column(
            Modifier
                .align(Alignment.BottomCenter)
                .nestedScroll(scrollBehavior.nestedScrollConnection)
        ) {
            UserInput(
                onMessageSent = { content ->
                    val timeNow = getTimeNow()
                    val message = Message("me", content, timeNow)
                    viewModel.sendMessage(message)
                },
                resetScroll = {
                    scope.launch {
                        scrollState.scrollToItem(0)
                    }
                },
                // Use navigationBarsWithImePadding(), to move the input panel above both the
                // navigation bar, and on-screen keyboard (IME)
                modifier = Modifier.userInputModifier(),
            )
        }
        ChannelNameBar(
            channelName = messagesState.channelName,
            channelMembers = messagesState.channelMembers,
            onNavIconPressed = onNavIconPressed,
            scrollBehavior = scrollBehavior,
            // Use statusBarsPadding() to move the app bar content below the status bar
            modifier = Modifier.statusBarsPaddingMpp(),
        )
    }
}

In order to preserve the convenient Android styles that are applied using the Accompanist library, we will use the convenient expect / actual modifier, which allows you to customize functions, classes and variables depending on the platform. To do this, create a platform package in commonMain and write the function userInputModifier. In reality, it will only affect the display on Android.

// shared/src/commonMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier

// shared/src/androidMain/kotlin/platform/modifier.kt
actual fun Modifier.userInputModifier(): Modifier = this.navigationBarsWithImePadding()

// shared/src/desktopMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this

// shared/src/iosMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this

// shared/src/jsMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this

Other features that depend on the logic of the platform itself, and not on specific dependencies, are the cursor. It is assumed that on Android and iOS it is not needed, but it is hard to imagine without it on the web and desktop versions.

And again – in commonMain announce expect fun, and to other implementation sources for specific targets. It’s very simple, for example, for desktopMain so let’s write:

actual fun Modifier.pointerCursor() = pointerHoverIcon(PointerIcon.Hand, true)

For convenient enum class PointerIcon the desktop actually hides an AWT event that changes the appearance of the cursor. In a web environment where the interface is not “native”, this method will not work, and you will have to play with … CSS styles. Is it a crutch? Undoubtedly. Let’s hope that over time the Compose Multiplatform team will roll out a beautiful solution for the web, but for now we write as it is:

actual fun Modifier.pointerCursor() = composed {
    val hovered = remember { mutableStateOf(false) }

    if (hovered.value) {
        document.body?.style?.cursor = "pointer"
    } else {
        document.body?.style?.cursor = "default"
    }

    this.pointerInput(Unit) {
        awaitPointerEventScope {
            while (true) {
                val pass = PointerEventPass.Main
                val event = awaitPointerEvent(pass)
                val isOutsideRelease = event.type == PointerEventType.Release &&
                        event.changes[0].isOutOfBounds(size, Size.Zero)
                hovered.value = event.type != PointerEventType.Exit && !isOutsideRelease
            }
        }
    }
}

Wonderful! We have a completely working messenger prototype that works on Android, iOS, in a browser and a standalone application!

image

What else interesting can be done with our messenger? As an option, add a theme switch in the interface. In the initial implementation of JetChat, the theme depends on the main theme of the system. That’s cool, but how do you make it switchable at the user’s request?

To do this, we indicate in build.gradle.kts in sourceset commonMain dependencies implementation(compose.material3) And implementation(compose.materialIconsExtended)create variables AppLightColorScheme And AppDarkColorScheme type ColorScheme with colors for light and dark theme respectively and then pass one of the variables to the class MaterialTheme:

@Composable
@Suppress("FunctionName")
fun ThemeWrapper(
    viewModel: MainViewModel
) {
    val theme by viewModel.themeMode.collectAsState()
    ApplicationTheme(theme) {
        Column {
            Conversation(
                viewModel = viewModel
            )
        }
    }
}

@Composable
fun ApplicationTheme(
    theme: ThemeMode = isSystemInDarkTheme().toTheme(),
    content: @Composable () -> Unit,
) {
    val myColorScheme = when (theme) {
        ThemeMode.DARK -> AppDarkColorScheme
        ThemeMode.LIGHT -> AppLightColorScheme
    }

    MaterialTheme(
        colorScheme = myColorScheme,
        typography = JetchatTypography
    ) {
        val rippleIndication = rememberRipple()
        CompositionLocalProvider(
            LocalIndication provides rippleIndication,
            content = content
        )
    }
}

All that remains for us is to write in MainViewModel new StateFlow and a method to change it:

private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
    val themeMode: StateFlow<ThemeMode> = _themeMode

fun switchTheme(theme: ThemeMode) {
        _themeMode.value = theme
    }

Finishing touch – add to Drawer our application function Switchavailable in the compose out of the box, and change the theme depending on the value of this small but proud widget:

AppScaffold(
        scaffoldState = scaffoldState,
        viewModel = viewModel,
        onChatClicked = { title ->
            viewModel.setCurrentConversation(title)
            coroutineScope.launch {
                scaffoldState.drawerState.close()
            }
        },
        onProfileClicked = { userId ->
            viewModel.setCurrentAccount(userId)
            coroutineScope.launch {
                scaffoldState.drawerState.close()
            }
        },
        onThemeChange = { value ->
            viewModel.switchTheme(value.toTheme())
        }
    ) {...}


@Composable
@Suppress("FunctionName")
fun ThemeSwitch(viewModel: MainViewModel, onThemeChange: (Boolean) -> Unit) {
    Box(
        Modifier
            .defaultMinSize(300.dp, 48.dp)
            .fillMaxSize()
    ) {
        Row(
            modifier = Modifier
                .height(56.dp)
                .fillMaxWidth()
                .padding(horizontal = 12.dp)
                .clip(CircleShape)
        ) {

            val checkedState by viewModel.themeMode.collectAsState()
            val iconColor = MaterialTheme.colorScheme.onSecondary
            val commonModifier = Modifier.align(Alignment.CenterVertically)
            Icon(
                imageVector = Icons.Outlined.LightMode,
                contentDescription = "Light theme",
                modifier = commonModifier,
                tint = iconColor
            )
            Switch(
                checked = checkedState.toBoolean(),
                onCheckedChange = {
                    onThemeChange(it)
                },
                modifier = commonModifier
            )
            Icon(
                imageVector = Icons.Outlined.DarkMode,
                contentDescription = "Dark theme",
                modifier = commonModifier,
                tint = iconColor
            )
        }
    }
}

The result is the ability to switch from light to dark and back on all platforms! 🙌

image

Perhaps I’ll stop here for now so as not to go far beyond the 10-minute read. In fact, I have already managed to connect a very mock application to a Websocket server using Ktor Multiplatform, but I hope to cover this and other new features of it in future articles.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *