-
-
Save Skaldebane/fea5c8c24193c7b05c7f5bbd099a100b to your computer and use it in GitHub Desktop.
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) | |
} | |
} | |
} | |
} | |
) | |
} |
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 theinlineContent
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:
- 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. - The ids must be unique, otherwise the
alternateText
gets overridden by the last usage of theid
(in this case, both radicals would say "another radical").
Since I rely onalternateText
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:
- a
Placeholder
, which sets theheight
,width
, and vertical alignment. - 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 liketoDpSize
,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:
- 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!).
- I use a
Canvas
to draw the text, and then usedrawText
inside it, passing thelayoutResult
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 some0.00001px
inaccuracy perhaps)
- We already measured the text with the right styles usingTextMeasurer
, so we won't have to pass styles again.
- 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!
がんばって! 🐱
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!
Hi there! Just took a shot at this today.
Albeit not too complicated, it's unfortunately not trivial, but I think this is the simplest it could be in Compose today.
This uses inline content, and a bunch of small things.
Since this relies on how you store these color styles after parsing, where you store colors, etc... you'll have to adapt it to your code.