kotlinkotlin-multiplatformcompose-multiplatform

Shared resources not working in compose multiplatform library


I created a compose-multiplatform library for sharing UI between my projects following the guide of this website https://medium.com/@shubhamsinghshubham777/how-to-write-a-compose-multiplatform-library-66ae1b7edb81. In the library I have some composeables where I use drawables (e.g. icon_xy.xml) in an Image-Composable. E.g:

@Composable
fun SimpleLoadingBar(
    modifier: Modifier = Modifier.size(LOADING_BAR_WIDTH.dp),
    isLoading: MutableState<Boolean> = remember { mutableStateOf(true) },
) {
    val rotation = remember { Animatable(0f) }
    LaunchedEffect(isLoading) {
        if (isLoading.value) {
            // Animiere die Rotation unendlich
            rotation.animateTo(
                targetValue = 360f,
                animationSpec = infiniteRepeatable(
                    animation = tween(durationMillis = 1000, easing = EaseOutQuart),
                    repeatMode = RepeatMode.Restart
                )
            )
        } else {
            // Stoppe die Animation, wenn nicht geladen wird
            rotation.stop()
        }
    }

    Image(
        painter = painterResource(DrawableResource("icons/icon_xy.xml")),
        contentDescription = "Loading Animation",
        modifier = modifier.graphicsLayer(rotationZ = rotation.value),
        contentScale = ContentScale.Fit
    )
}

When I use the library in the sample compose app (included by implementation(project(":library")) in the commonMain dependencies or by using an artifactory), the resources are working for desktop and android, but in web and iOS the resources are not found/working. When I use them in the compose-app directly, the resources are working. Anyone got a clue what i could do?

Here is my library build-gradle:

import org.jetbrains.kotlin.cli.common.toBooleanLenient
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl

plugins {
    //Versions are being handled in libs.versions.toml

    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.jetbrainsCompose)
    alias(libs.plugins.kotlinCocoapods)
    alias(libs.plugins.mavenPublish)
}

kotlin {
    androidTarget {
        publishLibraryVariants("release")
    }

    jvm("desktop")

    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        browser()
        binaries.executable()
    }

    iosArm64()
    iosX64()
    iosSimulatorArm64()

    cocoapods {
        version = when (shouldPublish()) {
            true -> getArtifactVersion()
            false -> "local"
        }
        summary = "Shared UI Elements for stackoverflow"
        homepage = "empty"

        ios.deploymentTarget = libs.versions.ios.deploymentTarget.get()
        framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(compose.runtime)
                implementation(compose.foundation)
                implementation(compose.material3)
                implementation(compose.components.resources)
                implementation(libs.kotlin.dateTime)
            }
        }
        val androidMain by getting {
            dependsOn(commonMain)
            dependencies {
            }
        }
        val desktopMain by getting {
            dependsOn(commonMain)
            dependencies {
                implementation(compose.desktop.common)
            }
        }
        val wasmJsMain by getting {
            dependsOn(commonMain)
            dependencies {
            }
        }
        val iosMain by creating {
            dependsOn(commonMain)
            dependencies {
            }
        }
        val iosX64Main by getting {
            dependsOn(iosMain)
        }
        val iosArm64Main by getting {
            dependsOn(iosMain)
        }
        val iosSimulatorArm64Main by getting {
            dependsOn(iosMain)
        }
    }

    withSourcesJar()
}

android {
    compileSdk = libs.versions.android.compileSdk.intValue
    namespace = "com.stackoverflow.shared_ui"

    sourceSets["main"].res.srcDirs("src/androidMain/res")
    sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
    sourceSets["main"].resources.srcDirs("src/commonMain/resources")

    defaultConfig {
        minSdk = libs.versions.android.minSdk.intValue
    }

    compileOptions {
        sourceCompatibility = JavaVersion.toVersion(libs.versions.android.javaVersion.intValue)
        targetCompatibility = JavaVersion.toVersion(libs.versions.android.javaVersion.intValue)
    }
    kotlin {
        jvmToolchain(libs.versions.android.javaVersion.intValue)
    }
}

publishing {
    if (!shouldPublish()) return@publishing

    repositories {
        maven {
            url = uri(getEnvValue("REGISTRY_URL"))
            credentials {
                username = getEnvValue("REGISTRY_USERNAME")
                password = getEnvValue("REGISTRY_PASSWORD")
            }
        }
    }
}

tasks.register("printPackageName") {
    doLast {
        println("Published and Released version ${getArtifactVersion()} ✅")
    }
}

mavenPublishing {
    if (!shouldPublish()) return@mavenPublishing

    coordinates("com.stackoverflow.shared_ui", "shared-ui", getArtifactVersion())

    pom {
        name.set(project.name)
        description.set("Shared UI Elements for stackoverflow.")
        inceptionYear.set("2023")
        scm {
            val projectLocation = "github.com/${getEnvValue("GITHUB_REPOSITORY")}"

            url.set("https://$projectLocation")
            connection.set("scm:git:git://$projectLocation.git")
            developerConnection.set("scm:git:ssh://git@$projectLocation.git")
        }
    }
}

val Provider<String>.intValue get() = get().toInt()

fun getEnvValue(name: String) = System.getenv(name) ?: throw Exception("$name not given")

fun findIntProperty(name: String) = (findProperty(name) as String?)?.toInt() ?: throw Exception("$name not given")

fun shouldPublish() = System.getenv("PUBLISH")?.toBooleanLenient() == true

fun getArtifactVersion() = getEnvValue("GITHUB_HEAD_REF").replace('/', '-') +
        "-${getEnvValue("GITHUB_RUN_ID")}-${getEnvValue("GITHUB_RUN_ATTEMPT")}"

And here is my sample compose-app build-gradle:

import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.jetbrainsCompose)
}

kotlin {
    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        moduleName = "composeApp"
        browser {
            commonWebpackConfig {
                outputFileName = "composeApp.js"
            }
        }
        binaries.executable()
    }
    
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "1.8"
            }
        }
    }
    
    jvm("desktop")
    
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }
    
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(project(":library"))
                implementation(compose.runtime)
                implementation(compose.foundation)
                implementation(compose.material)
                implementation(compose.ui)
                @OptIn(ExperimentalComposeLibrary::class)
                implementation(compose.components.resources)
            }
        }
        val desktopMain by getting {
            dependsOn(commonMain)
            dependencies {
                implementation(compose.desktop.currentOs)
            }
        }
        
        androidMain.dependencies {
            implementation(libs.compose.ui.tooling.preview)
            implementation(libs.androidx.activity.compose)
        }
        val wasmJsMain by getting {
            dependsOn(commonMain)
            dependencies {
            }
        }

        val iosX64Main by getting
        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting
        val iosMain by creating {
            dependsOn(commonMain)
            iosX64Main.dependsOn(this)
            iosArm64Main.dependsOn(this)
            iosSimulatorArm64Main.dependsOn(this)
        }
    }
}

android {
    namespace = "com.stackoverflow.shared.ui"
    compileSdk = libs.versions.android.compileSdk.get().toInt()

    sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
    sourceSets["main"].res.srcDirs("src/androidMain/res")
    sourceSets["main"].resources.srcDirs("src/commonMain/resources")

    defaultConfig {
        applicationId = "com.stackoverflow.shared.ui"
        minSdk = libs.versions.android.minSdk.get().toInt()
        targetSdk = libs.versions.android.targetSdk.get().toInt()
        versionCode = 1
        versionName = "1.0"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    dependencies {
        debugImplementation(libs.compose.ui.tooling)
    }
}

compose.desktop {
    application {
        mainClass = "MainKt"

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "com.stackoverflow.shared.ui"
            packageVersion = "1.0.0"
        }
    }
}

compose.experimental {
    web.application {}
}

And the versions are defined in libs.versions.toml:

[versions]
agp = "8.2.2"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
android-javaVersion = "17"
ios-deploymentTarget = "11.0"
androidx-activityCompose = "1.8.2"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.12.0"
androidx-espresso-core = "3.5.1"
androidx-material = "1.11.0"
androidx-test-junit = "1.1.5"
compose = "1.6.1"
compose-plugin = "1.6.0-beta02"
junit = "4.13.2"
kotlin = "1.9.22"
publish-version = "0.25.3"
kotlin-dateTime = "0.5.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
kotlin-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlin-dateTime"}

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "publish-version" }


Solution

  • Starting with 1.6.10, you can place resources in any module or source set, as long as you are using Kotlin 2.0.0 or newer, and Gradle 7.6 or newer.

    source: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-images-resources.html

    I made it work after adding the following to my KMM library:

    compose.resources {
        publicResClass = true
        packageOfResClass = "me.sample.library.resources"
        generateResClass = always
    }