Seamless Typography in Action: Implementing Custom Fonts in Compose Multiplatform for Android, iOS, and Desktop

Seamless Typography in Action: Implementing Custom Fonts in Compose Multiplatform for Android, iOS, and Desktop

I recently needed to use a custom font for my Compose multiplatform project Sync Sphere and I thought I'd share my solution in case it was helpful to anyone else.

First, we need to add a font to commonMain/resources/font

For this example, I have gone with dancingscript_regular.ttf The resource name should follow android resource conventions and be all lowercase with underscores if necessary.

I am adding this font to part of an app theme, but this part is optional.

@Composable
fun AppTheme(
    content:
    @Composable()
        () -> Unit,
) {

    MaterialTheme(
        content = content,
        typography = appTypography,
    )
}

appTypography is defined as a variable that creates a custom typography.

val appTypography : Typography
    @Composable
    get() {
        return Typography(
            defaultFontFamily = dancingScriptRegular
        )
    }

dancingScriptRegular is a custom font that we use expect/actual to get a different platform implementation.

val dancingScriptRegular: FontFamily
    @Composable
    get() {
        return FontFamily(
            font(
                "DancingScript",
                "dancingscript_regular",
                FontWeight.Normal,
                FontStyle.Normal,
            ),
        )
    }

The font function is the main bread and butter of our solution here. This is where we use expect/actual to give Android / iOS / Desktop implementations.

Under shared/src/commonMain/kotlin, I created a FontResource.kt class that looks like

import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight

@Composable
expect fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font

Under androidMain, iosMain, and desktopMain we will define our actual implemetations.

androidMain:

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight

@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)
}

iosMain:

import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.resource

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

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

desktopMain:

import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight

@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font =
    androidx.compose.ui.text.platform.Font("font/$res.ttf", weight, style)

Lastly, in your shared module build.gradle.kts under the android block, you'll have to add this line so that the Android app will know to use commonMain/resources and a res src directory.

    android {
    compileSdk = (findProperty("android.compileSdk") as String).toInt()
    namespace = "com.myapplication.common"

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

    // This is the line to add
    sourceSets["main"].res.srcDirs("src/commonMain/resources") // <=== This is the line to add
    // This is the line to add

    sourceSets["main"].resources.srcDirs("src/commonMain/resources")

    defaultConfig {
        minSdk = (findProperty("android.minSdk") as String).toInt()
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlin {
        jvmToolchain(17)
    }
}

And then you should be able to run your project on iOS, Android, and Desktop using a common font.

CocoaPods

A finishing note about CocoaPods. When I was writing this article, I was using the most up-to-date version of the Compose Multiplatform template on Github from JetBrains as a demo. When I was adding this to my project Sync Sphere, I had to do some additional work to get fonts working on iOS. After doing all of the steps listed above, I also had to run ./gradlew :shared:podInstall to get the fonts copied over to iOS, otherwise I was running into a resource not found exception.

Conclusion

I hope these series of code snippets can prove helpful to someone implementing custom typography in their Compose Multiplatform app.