Blog Infos
Author
Published
Topics
, , ,
Published

Take a moment to observe the two screenshots gracing the cover image of this article, each portraying the home screen of a 2021 Spotify-inspired app. Can you determine which of these was created with Compose Multiplatform and which one was built using pure SwiftUI?

The first image showcases the Compose Multiplatform implementation, while the second depicts the SwiftUI version. This striking resemblance underscores the power of Compose Multiplatform in delivering native UI experiences across platforms.

Kotlin Multiplatform’s Objective: Facilitating Code Sharing

The primary objective of Kotlin Multiplatform (KMP) is to facilitate code sharing across various platforms, effectively reducing development costs. Unlike other cross-platform tools, KMP allows for extensive code sharing, from the data layer to the business logic and presentation layer. With the recent release of Compose Multiplatform, this extends to the UI layer as well, enabling developers to build entire applications — from desktop to Android and iOS — using Kotlin Multiplatform.

Previous Endeavor: Currency Converter Application

Before we go into the details of this project, it’s worth briefly revisiting a previous endeavor. About a year ago, I embarked on a project to build a currency converter app using Kotlin Multiplatform. In that project, I successfully shared everything but the UI across platforms — leveraging code for the data layer, business logic, and view models — while maintaining a native UI for each platform.

Exploring New Technologies with Compose Multiplatform

As developers, we are inherently curious and eager to explore new technologies. So, when Compose Multiplatform was released, I was keen to see how it could streamline the development process and enhance cross-platform compatibility in my latest project. This article chronicles my journey into the world of Compose Multiplatform, exploring its potential and evaluating its suitability for real-world projects.

Skia and Skiko: The Core Rendering Engine and Wrapper

Compose Multiplatform utilizes Skia under the hood to render the UI, a powerful 2D graphics library developed by Google. Skia’s versatility enables it to work seamlessly across various platforms. Interestingly, Skia is also the underlying rendering engine for Flutter, Google’s cross-platform UI toolkit. Jetpack Compose, Google’s UI toolkit for Android, also relies on Skia for rendering, although indirectly. However, Compose Multiplatform, developed by JetBrains, employs a Kotlin multiplatform library called Skiko. Skiko serves as a wrapper for the Skia library, tailored specifically for Kotlin. In essence, Skiko provides the bridge between Kotlin Multiplatform and Skia, enabling developers to leverage the power of Skia for rendering UI components in their cross-platform projects seamlessly.

For my latest project, I had the opportunity to work with a Spotify-inspired Figma design, which served as the foundation for developing the UIs in both Compose Multiplatform and SwiftUI.

Setting Up the Project in Android Studio

I initiated a new Android Studio project, leveraging the Kotlin Multiplatform App template:

Afterward, I updated my libs.versions.toml file with the latest stable Compose Multiplatform version (1.6.11 at the time of writing):

[versions]
agp = "8.2.2"
kotlin = "2.0.0"
compose = "1.6.11"
androidx-activityCompose = "1.9.0"
androidx-appCompat = "1.7.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-app-compat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appCompat" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Integrating Compose Plugin and Dependencies

Subsequently, I integrated the Compose plugin into the project. The integration process involved applying this plugin across various files: the project-level build.gradle.kts, the common module’s build.gradle.kts, and the Android module’s build.gradle.kts. Within the common module’s build.gradle.kts, I specified the Compose dependencies required for the project:

val commonMain by getting {
    dependencies {
        implementation(compose.runtime)
        implementation(compose.foundation)
        implementation(compose.material3)
    }
}

This step ensured that the necessary Compose libraries were included, paving the way for seamless UI development across platforms. The entry point composable of this application is the SpotifyUIApp composable.

Entry Point Integration: Android and iOS

On the Android side, integrating the entry point composable is straightforward. In the MainActivity class of our Android project, we simply call the SpotifyUIApp composable within the setContent block:

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

However, integrating the entry point composable into the iOS environment requires a slightly different approach. We need to wrap the composable in a ViewController in our iosMain module. Compose Multiplatform provides the ComposeUIViewController function to accomplish this:

class SpotifyUIViewControllerFactory {
    fun create(): UIViewController {
        return ComposeUIViewController { 
            SpotifyUIApp()
        }
    }
}

Then, within the iOS project, we import the shared module and create a SwiftUI view from the controller:

import common
import Foundation
import SwiftUI

struct SpotifyUIViewController: UIViewControllerRepresentable {

    func updateUIViewController(_ uiViewController: UIViewControllerType,context: Context) {}
    
    func makeUIViewController(context: Context) -> some UIViewController {
        let factory = SpotifyUIViewControllerFactory()
        return factory.create()
    }
}

Finally, we render the view in theContentView:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            SpotifyUIViewController()
        }
    }
}
Implementing Edge-to-Edge Display

My initial focus was on the StartScreen:

Start Screen (Figma)

The design specifications dictated that the background should seamlessly extend behind the status bars, as illustrated in the design mockup above.

Presently, achieving this effect in a Multiplatform manner is not possible without using an external library, thus requiring platform-specific implementations. For Android, achieving edge-to-edge display involves utilizing the enableEdgeToEdge feature:

Start Screen (Android)
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SpotifyUIApp()
        }
    }
}
Start Screen (iOS)
Consistent Dark Theme Implementation

Moving forward, adhering to the design specifications required a consistent dark theme across the application. Ideally, achieving this in a multiplatform manner would be desirable. However, as of now, such functionality is not yet available, requiring platform-specific implementations as well.

On Android, enabling the dark theme involves several steps. First, it’s crucial to ensure that the base activity is an AppCompatActivity rather than a ComponentActivity to prevent potential crashes. Additionally, the theme or style must be an AppCompat theme, such as DayNightTheme. Once these prerequisites are met, setting the dark theme can be done in MainActivity, following the enabling of edge-to-edge display:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
        setContent {
            SpotifyUIApp()
        }
    }
}

With these adjustments in place, the dark theme functionality is successfully implemented on Android.

Conversely, on iOS, achieving the dark theme is notably more straightforward. By simply adding the preferredColorScheme(.dark) modifier, the application adopts the desired dark theme seamlessly:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            SpotifyUIViewController()
                .preferredColorScheme(.dark)
                .ignoresSafeArea(.all)
        }
    }
}
Platform-Specific Navigation Icons

Let’s proceed to the Signup screen:

Positioned at the top left corner is a back icon — a familiar navigation element seen across platforms. While Android typically employs a back arrow icon for this purpose, iOS typically features a back chevron icon. To maintain platform consistency, I aimed to implement separate icons for each platform. To achieve this, I utilized a straightforward approach leveraging expect/actual to figure out the platform at runtime:

sealed class Platform {
    data object Ios : Platform()
    data object Android : Platform()
}

expect val platform: Platform
actual val platform: Platform = Platform.Android
actual val platform: Platform = Platform.Ios

I then defined a Back icon composable that dynamically returns an ImageVector based on the platform. On iOS, the composable returns the chevron back icon, while on Android, it provides the Material Design back icon:

@OptIn(ExperimentalResourceApi::class)
object SpotifyUIAppIcons {
    val Back: ImageVector
        @Composable
        get() {
            return when (platform) {
                Platform.Ios -> vectorResource(Res.drawable.ic_chevron_back_ios)
                else -> Icons.AutoMirrored.Default.ArrowBack
            }
        }
}

Job Offers

Job Offers


    Senior Android Developer

    SumUp
    Berlin
    • Full Time
    apply now

    Senior Android Engineer

    Carly Solutions GmbH
    Munich
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

, ,

Writing Kotlin Multiplatform libraries that your iOS teammates are gonna love

Kotlin Multiplatform Mobile (KMM) is awesome for us Android Developers. Writing multiplatform code with it doesn’t diverge much from our usual routine, and now with Compose Multiplaform, we can write an entire iOS app without…
Watch Video

Writing Kotlin Multiplatform libraries that your iOS teammates are gonna love

André Oriani
Principal Software Engineer
Walmart

Writing Kotlin Multiplatform libraries that your iOS teammates are gonna love

André Oriani
Principal Software E ...
Walmart

Writing Kotlin Multiplatform libraries that your iOS teammates are gonna love

André Oriani
Principal Software Engine ...
Walmart

Jobs

Navigation Options

When it comes to navigation, developers have several options to consider. While an experimental Compose Multiplatform library for navigation exists, it’s worth noting that it lacks type safety, relying on raw strings for destinations and navigation arguments. However, with the recent release of the type-safe navigation compose library in Jetpack Compose, there’s hope for a similar solution in Compose Multiplatform in the future. In the meantime, various robust and open-source type-safe navigation libraries are available, such as DecomposeDecompose-Router, and Voyager.

After evaluating these alternatives, I opted to proceed with Voyager due to its intuitive API, which aligns well with my project requirements and preferences. To integrate Voyager into your project, you’ll need to add the necessary dependencies to your commonMain dependencies block:

[versions]
agp = "8.2.2"
kotlin = "2.0.0"
compose = "1.6.11"
androidx-activityCompose = "1.9.0"
androidx-appCompat = "1.7.0"
voyager = "1.0.0"
uuid = "0.8.2"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-app-compat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appCompat" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
val commonMain by getting {
    dependencies {
        implementation(compose.runtime)
        implementation(compose.foundation)
        implementation(compose.material3)
        implementation(libs.voyager.navigator)
        implementation(libs.voyager.transitions)
        implementation(libs.uuid)
    }
}

Then, you can configure Voyager as follows:

@Composable
internal fun AppScreen() {
    val useCase = remember { IsUserLoggedIn.INSTANCE }
    val isLoggedIn by useCase.execute().collectAsState(initial = false)

    SpotifyUITheme {
        if (isLoggedIn) {
            Navigator(MainScreen()) { navigator ->
                SlideTransition(navigator)
            }
        } else {
            Navigator(StartScreen()) { navigator ->
                SlideTransition(navigator)
            }
        }
    }
}

In my setup, I employ two distinct navigators or navigation graphs: one tailored for scenarios where the user is logged out and another for when they’re logged in. To transition to another screen, you can easily achieve this by calling Navigator.push, passing the desired screen as the parameter:

class StartScreen : Screen {
    @Composable
    override fun Content() {
        val navigator = LocalNavigator.currentOrThrow

        StartScreenContent(
            onSignupFreeClicked = {
                navigator.push(SignupScreen())
            }
        )
    }
}
Handling Resources in Compose Multiplatform

Moving on to resources. Resources are any static content like images, fonts, and strings that are utilized in an application. To integrate resources into Compose Multiplatform, we first need to include the Compose resources dependency in our commonMain source set:

val commonMain by getting {
    dependencies {
        implementation(compose.runtime)
        implementation(compose.foundation)
        implementation(compose.material3)
        implementation(libs.voyager.navigator)
        implementation(libs.voyager.transitions)
        implementation(libs.uuid)
        implementation(compose.components.resources)
    }
}

Resources such as images, strings, and fonts should all be organized within a composeResource directory in commonMain. The composeResource directory structure should adhere to the following guidelines:

  • Images should reside in the composeResource/drawable subdirectory.
  • Fonts should be placed in the composeResource/font subdirectory.
  • Strings should be organized within the composeResource/values subdirectory.
  • Any other file types should be stored in the composeResource/files directory.

These guidelines are crucial as Compose Multiplatform employs them to generate typesafe accessors for all resource types, excluding those within the files subdirectory. For example, accessing a background.png image placed in the composeResource/drawable can be achieved through the generated Res object like so: painterResource(Res.drawable.background) .

A minor inconvenience encountered when dealing with SVG images in this context is the inability to utilize Android Studio’s Vector Asset Studio (SVG to vector tool) directly from the commonMain source set. Currently, my approach involves initially converting the SVG file to a drawable vector in the Android module using the Vector Asset Studio. After this conversion, I manually copy it to the commonMain source set.

Similar to the pattern used in native android development, Compose Multiplatform’s resources api allows you to provide variations of the same resource for different use cases such as screen density, locale or theme.
In this project, for example, we have two values subdirectories for our string resources: one for default strings in English and another for alternate strings in the Spanish locale:

When the device language or locale is switched to Spanish, the string resources dynamically adapt to reflect the current locale. Currently, there’s no built-in mechanism to switch locales from within your Compose Multiplatform app.

One other issue I noticed with string resources in Compose Multiplatform is that it currently does not support the apostrophe character in the XML file, even when attempting to escape it with the backslash (/) character. Instead of escaping the apostrophe, the backslash character itself is rendered on screen, unlike the typical behavior on native Android. The only way to get around it currently is with the \u0027s unicode character. So this:

<string name="signup_email_question">What\'s your email?</string>

becomes this:

<string name="signup_email_question">What\u0027s your email?</string>

This workaround can pose challenges for translators who may find the Unicode characters confusing or difficult to work with when translating strings to other locales, potentially leading to inaccuracies or translation errors.

On to Fonts. To incorporate custom fonts in a Compose Multiplatform project, as mentioned earlier, you must place them in the composeResource/font directory. Once this is done, you can access them using the generated Res accessor within your Theme to change the font of your text:

@OptIn(ExperimentalResourceApi::class)
@Composable
internal fun SpotifyUITheme(content: @Composable () -> Unit) {
    val avenir = FontFamily(
        Font(Res.font.avenir_next_bold, FontWeight.ExtraBold, FontStyle.Normal),
        Font(Res.font.avenir_next_demi, FontWeight.Bold, FontStyle.Normal),
        Font(Res.font.avenir_next_medium, FontWeight.Medium, FontStyle.Normal),
        Font(Res.font.avenir_next_regular, FontWeight.Normal, FontStyle.Normal)
    )
...
Conclusion: Robust Solution with Caveats

In conclusion, it’s evident that Compose Multiplatform offers a robust solution for cross-platform UI development, making it well-suited for production environments even with Compose for iOS still in beta. However, there are notable caveats that deserve attention. Firstly, there’s no built-in mechanism to programmatically switch locales from within your Compose Multiplatform app, which can be a significant limitation for applications aiming to support localization. Additionally, the need to use Unicode characters for certain string resources, like apostrophes, may pose challenges for translators, potentially leading to inaccuracies or confusion in translated text.

Happy coding!

Special thanks to John O’Reilly and Mayowa Egbewunmi for taking the time to review this article.

Compose Multiplatform Projecthttps://github.com/KwabenBerko/Spotify-2021-Compose-Multiplatform

SwiftUI Projecthttps://github.com/KwabenBerko/Spotify-2021-SwiftUI

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu