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.