Last active
April 22, 2025 01:34
-
-
Save Skaldebane/fea5c8c24193c7b05c7f5bbd099a100b to your computer and use it in GitHub Desktop.
WaniKani radical/kanji/vocab/reading highlight colors for Compose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} | |
} | |
) | |
} |
I noticed you used Material 2, any chance it works in Material 3?
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).
Nevermind, got it.
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
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 anInlineTextContent
that holds the placeholder size and the@Composable
content.We pass this map to the
inlineContent
parameter in theText
/BasicText
composable.However, we first need to include the placeholders in the text itself, using an
AnnotatedString
. We useappendInlineContent
to add these inline content markers.appendInlineContent
takes anid
andalternateText
.id
is the unique string that will match the key in theinlineContent
map I mentioned above.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:
You can notice that in the
id
, I use the format$type-$index
, likekanji-0
andradical-1
.This isn't necessary, you could do this in many other ways, but I do this here because:
So I wanted to encode the type (radical, kanji, etc...) in the id itself for a very agnostic implementation.
alternateText
gets overridden by the last usage of theid
(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 theinlineContent
parameter of theText
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:
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:
Placeholder
, which sets theheight
,width
, and vertical alignment.@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:
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: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:
Now we have everything we need to create the
Placeholder
: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 usingtoSp()
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 ourplaceholder
toInlineTextContent
:First, I get the item type from the id:
And from here on, it's just UI.
You can see in the code above:
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 😢).Canvas
to draw the text, and then usedrawText
inside it, passing thelayoutResult
we got earlier.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!
がんばって! 🐱