I am developing an Android app using Jetpack Compose.
I want to implement a TextField. This TextField should display a small emoji or image instead of text when a specific token is entered.
You can find such a case in the Slack app. Slack app supports custom emojis in addition to regular emojis.
I discovered InlineTextContent as a hint.
val inlineContentMap = mapOf(
"ab_12" to emojiImage("https://c.tenor.com/Rd6ULrCRvlQAAAAd/tenor.gif")
)
var inputText by remember { mutableStateOf("") }
BasicTextField(
value = inputText,
onValueChange = {
inputText = it
},
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Color.LightGray, RoundedCornerShape(4.dp))
.padding(8.dp),
textStyle = TextStyle(fontSize = 16.sp).copy(color = Color.Transparent),
visualTransformation = EmojiVisualTransformation,
decorationBox = { innerTextField ->
Box(
modifier = Modifier,
contentAlignment = Alignment.TopStart
) {
val annotatedString = replaceTokens(inputText)
Text(
text = annotatedString,
style = TextStyle(fontSize = 16.sp),
inlineContent = inlineContentMap
)
innerTextField()
}
}
)
and here are other functions:
@Composable
private fun emojiImage(imgUrl: String) =
InlineTextContent(Placeholder(16.sp, 16.sp, PlaceholderVerticalAlign.TextCenter)) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
add(ImageDecoderDecoder.Factory())
}
.build()
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(data = imgUrl)
.build(),
imageLoader = imageLoader
),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
private fun replaceTokens(input: String): AnnotatedString {
val annotatedString = buildAnnotatedString {
val regex = Regex("\\{:(\\w+_\\d+):\\}")
var currentIndex = 0
regex.findAll(input).forEach { matchResult ->
val token = matchResult.groupValues[1]
val tokenIndex = matchResult.range.first
val tokenLength = matchResult.value.length
append(input.substring(currentIndex, tokenIndex))
appendInlineContent(id = token)
currentIndex = tokenIndex + tokenLength
}
append(input.substring(currentIndex, input.length))
}
return annotatedString
}
Here is the VisualTransformation:
object EmojiVisualTransformation: VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val originText = text.text
val annotatedString = replaceTokens(originText)
return TransformedText(annotatedString, EmojiOffsetMapping(originText, annotatedString.text))
}
}
data class EmojiOffsetMapping(
private val origin: String,
private val transformed: String,
): OffsetMapping {
private val tokenPositionRanges = mutableListOf<IntRange>()
init {
val regex = Regex("\\{:(\\w+_\\d+):\\}")
regex.findAll(origin).forEach { matchResult ->
val tokenPosition = matchResult.range
tokenPositionRanges.add(tokenPosition)
}
}
private fun getTransformedOffset(offset: Int): Int {
if (offset == 0) {
return 0
}
var newOffset = offset
tokenPositionRanges.forEachIndexed { index, tokenRange ->
if (tokenRange.first + 1 <= offset && offset <= tokenRange.last + 1) {
return index + 1
}
newOffset = newOffset - tokenRange.count() + 1
}
return newOffset
}
override fun originalToTransformed(offset: Int): Int {
// return getTransformedOffset(offset)
return offset
}
override fun transformedToOriginal(offset: Int): Int {
return offset
}
}
This code works only in very simple cases and often crashes in most cases.
While InlineTextContent makes it easy to display images between strings, the problem lies with the cursor.
In the case of the tokens I'm using in my format {:d_1:}, {:ab_10:}, they occupy 7 to 9 or more characters, resulting in invisible spaces even though they appear replaced by a single image.
This makes the cursor behavior very erratic.
In fact, I'm not sure if using InlineTextContent and VisualTransformation is the right approach anymore.
How can I implement this TextField effectively?"
[EDIT 1]
One more question:
In the Slack app, placing the cursor directly after an emoji and pressing the backspace key deletes the emoji.
However, in the current implementation, instead of deleting the emoji, pressing the backspace key deletes the last character of the token, causing the emoji to revert and the token to be exposed.
For example, instead of deleting the entire "{:ab_12:}" token, only the last curly brace '}' is deleted, leaving "{:ab_12:" exposed.
How should we handle deleting the entire token when pressing the backspace key?
[EDIT 2]
To enable the emoji to be deleted properly when the user places the cursor directly after it and presses the backspace key, more complex cursor control is needed.
I'm not sure if this is even possible. Typically, in BasicTextField, once the backspace key is pressed, the text is already deleted, making it difficult to determine which character was deleted.
After much deliberation, I thought that perhaps assigning an ImageSpan representing a single empty character (Unicode like U+0020) rather than assigning a String containing the token to the value of BasicTextField might be a better way to handle the backspace key.
I also attempted to pass TextFieldValue instead of String to the value of BasicTextField, but this also failed to achieve the goal.
Although I succeeded in replacing the token with an image in AnnotatedString when creating TextFieldValue in onValueChange, when the string replaced with the Unicode empty character is called again in onValueChange, the position information annotated in AnnotatedString is reset.
While it's evident that this functionality is implemented in major commercial apps like Slack, finding related libraries is challenging. How could they have implemented it?
