Skip to content

Instantly share code, notes, and snippets.

@Skaldebane
Last active April 22, 2025 01:34
Show Gist options
  • Save Skaldebane/fea5c8c24193c7b05c7f5bbd099a100b to your computer and use it in GitHub Desktop.
Save Skaldebane/fea5c8c24193c7b05c7f5bbd099a100b to your computer and use it in GitHub Desktop.
WaniKani radical/kanji/vocab/reading highlight colors for Compose.
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.singleWindowApplication
fun main() = singleWindowApplication {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val text = buildAnnotatedString {
append("Testing: ")
appendInlineContent(id = "radical-0", alternateText = "radical")
append("s, ")
appendInlineContent(id = "kanji-0", alternateText = "kanji")
append(", ")
appendInlineContent(id = "radical-1", alternateText = "another radical")
append(", ")
appendInlineContent(id = "vocab-0", alternateText = "vocab")
append(", and ")
appendInlineContent(id = "reading-0", alternateText = "reading")
append("s.")
}
val style = LocalTextStyle.current
Text(
text = text,
style = style,
inlineContent = buildMap {
val annotations = text.getStringAnnotations(
tag = "androidx.compose.foundation.text.inlineContent",
start = 0, end = text.length
)
for (annotation in annotations) {
val layoutResult = textMeasurer.measure(
text = text.slice(annotation.start..<annotation.end).toString(),
style = style.merge(
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(.2f),
offset = with(density) { Offset(x = 0f, y = 1.dp.toPx()) }
)
)
)
val placeholderSize = with(density) { layoutResult.size.toSize().toDpSize() }
val placeholder = with(density) {
Placeholder(
width = ((placeholderSize.width + 8.dp).value / fontScale).sp,
height = ((placeholderSize.width + 2.dp).value / fontScale).sp,,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
}
this[annotation.item] = InlineTextContent(placeholder) {
val type = annotation.item.substringBefore("-")
Canvas(
modifier = Modifier
.drawBehind {
drawRoundRect(
color = when (type) {
"radical" -> Color.Black.copy(alpha = 0.2f).compositeOver(Color(0xFF0093DD))
"kanji" -> Color.Black.copy(alpha = 0.2f).compositeOver(Color(0xFFDD0093))
"vocab" -> Color.Black.copy(alpha = 0.2f).compositeOver(Color(0xFF9300DD))
else -> Color.Black.copy(alpha = 0.2f).compositeOver(Color(0xFF484848))
},
cornerRadius = CornerRadius(x = 4.dp.toPx(), y = 4.dp.toPx()),
)
drawRoundRect(
brush = when (type) {
"radical" -> Brush.verticalGradient(listOf(Color(0xFF00AAFF), Color(0xFF0093DD)))
"kanji" -> Brush.verticalGradient(listOf(Color(0xFFFF00AA), Color(0xFFDD0093)))
"vocab" -> Brush.verticalGradient(listOf(Color(0xFFAA00FF), Color(0xFF9300DD)))
else -> Brush.verticalGradient(listOf(Color(0xFF505050), Color(0xFF484848)))
},
cornerRadius = CornerRadius(x = 4.dp.toPx(), y = 4.dp.toPx()),
size = size.copy(height = size.height - 2.dp.toPx()),
)
}
.padding(horizontal = 4.dp, vertical = 1.dp)
.size(placeholderSize)
) {
drawText(layoutResult)
}
}
}
}
)
}
@Skaldebane
Copy link
Author

Skaldebane commented Apr 11, 2025

The way inline content works is as a map of key-value pairs, where the key is a String that acts as a unique id, and the value is an InlineTextContent that holds the placeholder size and the @Composable content.
We pass this map to the inlineContent parameter in the Text/BasicText composable.

However, we first need to include the placeholders in the text itself, using an AnnotatedString. We use appendInlineContent to add these inline content markers.
appendInlineContent takes an id and alternateText.

  • The id is the unique string that will match the key in the inlineContent map I mentioned above.
  • The alternateText is the text that should be there in case no inline content was provided. It's also passed to the inline content Composable, so it can also be used to show that text inside the Composable (which I do here).

Setting up the text:

Let's take a look at how I do this:

val text = buildAnnotatedString {
    append("Testing: ")
    appendInlineContent(id = "radical-0", alternateText = "radical")
    append("s, ")
    appendInlineContent(id = "kanji-0", alternateText = "kanji")
    append(", ")
    appendInlineContent(id = "radical-1", alternateText = "another radical")
    // ...
}

You can notice that in the id, I use the format $type-$index, like kanji-0 and radical-1.
This isn't necessary, you could do this in many other ways, but I do this here because:

  1. I don't have some other data structure that holds information about all of these annotations, their type, index, etc...
    So I wanted to encode the type (radical, kanji, etc...) in the id itself for a very agnostic implementation.
  2. The ids must be unique, otherwise the alternateText gets overridden by the last usage of the id (in this case, both radicals would say "another radical").
    Since I rely on alternateText to get the actual text (again, no data structure backing this), I can't afford to lose that, so I make each one unique by adding an index after the type.

Retrieving the annotations:

Now that our AnnotatedString is ready, we can get to the inlineContent parameter of the Text Composable.
To make matters easier, I'm using buildMap to create the map.

First thing, we need to retrieve all the annotations that we have to apply, so we can create the content for them.
Normally, you would rely on whatever data structure holds this info for you (or even create one for this specific use-case) and get the text and id from there. In my case, I'm getting them straight from the annotated string:

val annotations = text.getStringAnnotations(
    tag = "androidx.compose.foundation.text.inlineContent",
    start = 0, end = text.length
)

This code gets all string annotations with the tag androidx.compose.foundation.text.inlineContent on the whole text.
This is actually how Compose handles inline content annotations internally, they're just fancy string annotations, so we can retrieve them all like any other string annotation.

Creating the placeholder:

All of the above was specifics to how my implementation works, but what's below here is the stuff that won't change much.

Now it's time to create our inline content!
We need 2 things to do this:

  1. a Placeholder, which sets the height, width, and vertical alignment.
  2. a @Composable function.

One of the unfortunate things that makes this more complicated is that we need to specify the width and height ourselves. It can't automatically get them from the Composable.

Obviously, because this contains text, we can't predict what's its size going to be... so TextMeasurer comes to the rescue.
You can notice that I create an instance of a text measurer at the very top:

val textMeasurer = rememberTextMeasurer()

Now we can use it to measure our text in a style. Here I'm just using LocalTextStyle.current with a white color and shadow applied, but you should use the same style you're applying to the rest of the text (while also applying white color and text shadow in the measurer).

We use the measure function as follows:

val layoutResult = textMeasurer.measure(
    text = text.slice(annotation.start..<annotation.end).toString(), // get the text marked by this annotation
    style = style.merge(
        color = Color.White,
        shadow = Shadow(
            color = Color.Black.copy(.2f),
            offset = with(density) { Offset(x = 0f, y = 1.dp.toPx()) }
        )
    )
)

This returns a TextLayoutResult, which includes a ton of information about this text (in fact, all of the information needed to draw it).
What we care about here is the size, so let's extract it:

val placeholderSize = with(density) { layoutResult.size.toSize().toDpSize() }

Note usages of with(density). This provides functions like toDpSize, toPx, toDp, toSp, and other density-aware conversion functions inside its scope, so conversions from pixels to scaled units are correct without having to do error-prone divisions / multiplications.

Now we have everything we need to create the Placeholder:

val placeholder = with(density) {
    Placeholder(
        width = ((placeholderSize.width + 8.dp).value / fontScale).sp, // 8dp added to accommodate for padding
        height = ((placeholderSize.height + 2.dp).value / fontScale).sp, // 2dp added to accommodate for padding
        placeholderVerticalAlign = PlaceholderVerticalAlign.Center
    )
}

I add a little extra space on top of the text size so we can add our paddings (on WK vertical padding is 1px and horizontal padding is 4px, so I add 1.dp x 2 and 4.dp x 2 to accommodate that, which we will apply in our Composable code).

I divide this by fontScale manually instead of using toSp() to avoid non-linear scaling on Android.

Creating the inline content:

Now it's time for us to add an entry to the map. Remember, we're inside buildMap, so we'll add a new key-value pair, passing our placeholder to InlineTextContent:

// annotation.item is the `id` we specified in the annotated string.
this[annotation.item] = InlineTextContent(placeholder) { alternateText ->
    // UI code
}

First, I get the item type from the id:

val type = annotation.item.substringBefore("-")

And from here on, it's just UI.

Canvas(
    modifier = Modifier
        .drawBehind {
            drawRoundRect(
                color = when (type) {
                    "radical" -> Color.Black.copy(alpha = 0.2f).compositeOver(Color(0xFF0093DD))
                    // other color setup
                },
                cornerRadius = CornerRadius(x = 4.dp.toPx(), y = 4.dp.toPx()),
            )
            drawRoundRect(
                brush = when (type) { ... }, // more color stuff
                cornerRadius = CornerRadius(x = 4.dp.toPx(), y = 4.dp.toPx()),
                size = size.copy(height = size.height - 2.dp.toPx())
            )
        }
        .padding(horizontal = 4.dp, vertical = 1.dp)
        .size(placeholderSize)
) {
    drawText(layoutResult)
}

You can see in the code above:

  1. I'm setting up the background using drawBehind. All it does is draw two rounded rectangles, where the top one is a little shorter so we get the 3d feeling. The color of the one on the back is just translucent black (rgba(0, 0, 0, 0.2) basically) composited over the item color, so it gives the same color as the inset shadow they're using on WK (as Compose doesn't have box-shadows yet 😢).

You already probably have these colors defined somewhere else in your app or as part of your theme, so use those instead.
However, I wouldn't recommend replacing the drawing itself, since this is tailored exactly for this use case, in a way that the text can actually draw over the dark strip at the bottom, so we make the best use of the limited space, and keep the inline text aligned with other text (and WK also works like that!).

  1. I use a Canvas to draw the text, and then use drawText inside it, passing the layoutResult we got earlier.

Why don't we just use Text? Two reasons:
- That causes weird bugs where the last character disappears in some cases because it thinks it has to wrap (because of some 0.00001px inaccuracy perhaps)
- We already measured the text with the right styles using TextMeasurer, so we won't have to pass styles again.

  1. I set the padding to padding(horizontal = 4.dp, vertical = 1.dp), and size to the placeholder size.

That's all! This was quite lengthy, if something is unclear, don't hesitate to ping me about it!

がんばって! 🐱

@Skaldebane
Copy link
Author

The output of this very code: image

@codejockie
Copy link

I noticed you used Material 2, any chance it works in Material 3?

@Skaldebane
Copy link
Author

Ah, this isn't material specific at all. I think the only thing I pulled from Material was the Text composable, as well as LocalTextStyle, so just replace them with whatever equivalents exist in Material 3 (in case of LocalTextStyle, you don't necessarily have to use it per se, just use the same TextStyle you do for the rest of the text).

@codejockie
Copy link

Nevermind, got it.

@codejockie
Copy link

Nice job, and very well done and explained!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment