Dynamic Font Sizes With Jetpack Compose

Oya Canlı
5 min readOct 21, 2023

--

Providing the user the possibility to choose the font sizes used in the app is a nice accessibility feature, appreciated by many users. Though, it was pretty complicated to implement this in the view world. I recently had to implement this in a Compose app and I initially had no idea how to implement it and I couldn’t find any examples or tutorials. Luckily, thanks to the flexibility of Compose I was able to find a very easy solution in no time.

In this article, I will try to show you how to implement something like this:

As usual, I prepared a simplied (and less nice looking) sample and if you want to directly jump to the code, here is the link to the github repo.

***

As you know, we are using a Typography class in our Compose themes to define all the fonts we use in our application. You might even have a custom one, it doesn’t matter. The key to easily implement this feature in your app and to make it work everywhere, is to apply the font size changes right there where they were defined: in the theme.

In our case, we wanted to decrease or increase all font sizes by plus/minus 2 pixels and provide 4 alternative font size sets. This can be classified with an enum:

enum class FontSizePrefs(
val key: String,
val fontSizeExtra: Int,
@DrawableRes val iconRes: Int,
) {
SMALL("S", -2, R.drawable.ic_font_size_small),
DEFAULT("M", 0, R.drawable.ic_font_size_default),
LARGE("L", 2, R.drawable.ic_font_size_large),
EXTRA_LARGE("XL", 4, R.drawable.ic_font_size_xlarge);

companion object {
fun getFontPrefFromKey(key: String?): FontSizePrefs {
return FontSizePrefs.entries.find {
it.key == key
} ?: DEFAULT
}
}
}

fontSizeExtra is the difference that will be applied in font sizes with respect to the default size. Icons are the icons we show in the selection menu and keys are used for saving user’s choice in preferences. Of course, you can adapt those to your needs.

Then, instead of the Typography object defined in the theme as a val, we convert it to a function, with the fontSizePrefs as an argument:

private const val lineHeightMultiplier = 1.15

fun getPersonalizedTypography(fontSizePrefs: FontSizePrefs): Typography {
return Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = (16 + fontSizePrefs.fontSizeExtra).sp,
lineHeight = ((16 + fontSizePrefs.fontSizeExtra) * lineHeightMultiplier).sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = (22 + fontSizePrefs.fontSizeExtra).sp,
lineHeight = ((22 + fontSizePrefs.fontSizeExtra) * lineHeightMultiplier).sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = (18 + fontSizePrefs.fontSizeExtra).sp,
lineHeight = ((18 + fontSizePrefs.fontSizeExtra) * lineHeightMultiplier).sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = (11 + fontSizePrefs.fontSizeExtra).sp,
lineHeight = ((11 + fontSizePrefs.fontSizeExtra) * lineHeightMultiplier).sp,
letterSpacing = 0.5.sp
),
)
}

Here we are simply applying the font size change requested by the user to all fonts defined in the theme. Plus 2, minus 2, simple maths.. Notice that changes are also applied to line heights. Because if you increase the font size without increasing the line height, your texts won’t look nice. In our case we use a line height multiplier, it could be also a simple addition. (I provide here a subset for the sake of simplicity, actually we need to adapt all fonts.)

Then in the app theme, font size preferences are passed as a parameter and typography is got based on font size preferences:

@Composable
fun DynamicFontSizesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
fontSizePrefs: FontSizePrefs = FontSizePrefs.DEFAULT,
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
// The rest of your theme definition here
// Doesn't matter if you use Material, Material3 or something custom
val colorScheme = when {..}

MaterialTheme(
colorScheme = colorScheme,
typography = getPersonalizedTypography(fontSizePrefs),
content = content
)
}

Finally, we pass the font size preferences of the user to the main theme at root level:

setContent {
val selectedFont by viewModel.fontSizeChoices.collectAsState()

DynamicFontSizesTheme(fontSizePrefs = fontSizeChoice) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
//..
}
}
}

Selected font sizes will be applied in all the view hierarchy and can be dynamically changed without relaunching the page. As easy as that!

If there is a part where you don’t want the sizes to be dynamically changed, you can override the theme there by simply wrapping that part in DynamicFontSizesTheme {} with default font size preferences. Little remark at this point: if you have the bad habbit of wrapping all your composables with your theme again and again below the hierarchy, it can override the preferences passed from root and then you might wonder why it is not working in some parts of the screen.

Now, let’s look at the implementation of the font selection menu. In our case this is an item in the top menu, so it can simply be added to the top bar with a DropdownMenu:

var showMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
DynamicFontSizesTheme {
TopAppBar(
title = {
Text("Dynamic Fonts")
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
),
actions = {
IconButton(onClick = {
showMenu = !showMenu
}) {
Icon(
painter = painterResource(id = R.drawable.ic_font_size_xlarge),
contentDescription = stringResource(id = R.string.change_font_size),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
FontSelectionMenu(
selectedFont = selectedFont,
onFontChosen = {
viewModel.setFontSize(it)
})
}
}
)
}
}
) { contentPadding ->
// .....

.. where FontSelectionMenu is as follows:

@Composable
fun FontSelectionMenu(
modifier: Modifier = Modifier,
selectedFont: FontSizePrefs = FontSizePrefs.DEFAULT,
onFontChosen: (FontSizePrefs) -> Unit,
) {
DynamicFontSizesTheme {
Column(
modifier = modifier
.width(285.dp)
.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(id = R.string.font_size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary)
.height(40.dp)
.padding(4.dp),
verticalAlignment = Alignment.Bottom,
) {
FontSizePrefs.entries.forEachIndexed { index, fontSizePref ->
FontChoiceItem(
isSelected = selectedFont == fontSizePref,
fontSizePref = fontSizePref,
onFontChosen = onFontChosen,
)
if (index < FontSizePrefs.entries.size - 1) {
Divider(
modifier = Modifier
.fillMaxHeight() // fill the max height
.width(1.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.secondaryContainer,
)
}
}
}
}
}
}

@Composable
fun RowScope.FontChoiceItem(
modifier: Modifier = Modifier,
isSelected: Boolean,
fontSizePref: FontSizePrefs,
onFontChosen: (FontSizePrefs) -> Unit,
) {
Box(
modifier = Modifier
.height(32.dp)
.weight(1f)
.background(if (isSelected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.secondary)
.clickable {
onFontChosen(fontSizePref)
}
.padding(bottom = 4.dp),
contentAlignment = Alignment.BottomCenter,
) {
Icon(
painter = painterResource(id = fontSizePref.iconRes),
contentDescription = stringResource(id = R.string.select_font_size, fontSizePref.key),
tint = if(isSelected) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSecondary,
modifier = modifier,
)
}
}

Notice that I have wrapped selection menu and the top bar with the theme with default font size preferences, because I didn’t want the font size changes to be applied there.

Of course, we should also save and persist user’s choices. You can save this in preferences or in data store. I won’t go over those details. You can see the complete sample on Github.

And that’s all! Overall, I think it was surprisingly easy to implement this with Compose and we are pretty proud to provide this nice feature to our users.

Thanks for reading and happy coding!

--

--