2

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
    }
}

enter image description here

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?

1 Answer 1

0

Instead of modifying the actual text inside BasicTextField, use a separate display layer that renders text and emojis correctly.

sealed class ChatItem {
    data class Text(val value: String) : ChatItem()
    data class Emoji(val url: String) : ChatItem()
}

We parse the input and separate text from emoji tokens.

private fun parseInput(input: String): List<ChatItem> {
    val regex = Regex("\\{:(\\w+_\\d+):\\}")
    val result = mutableListOf<ChatItem>()
    var lastIndex = 0

    regex.findAll(input).forEach { matchResult ->
        val token = matchResult.groupValues[1]
        val startIndex = matchResult.range.first

        // Add normal text before emoji
        if (lastIndex < startIndex) {
            result.add(ChatItem.Text(input.substring(lastIndex, startIndex)))
        }

        // Add emoji
        result.add(ChatItem.Emoji("https://c.tenor.com/Rd6ULrCRvlQAAAAd/tenor.gif"))

        lastIndex = matchResult.range.last + 1
    }

    // Add remaining text
    if (lastIndex < input.length) {
        result.add(ChatItem.Text(input.substring(lastIndex, input.length)))
    }

    return result
}

Instead of trying to manipulate cursor movement inside TextField, display a row with text and emojis.

@Composable
fun RichTextField(
    inputText: String,
    onTextChange: (String) -> Unit
) {
    var textFieldValue by remember { mutableStateOf(TextFieldValue(inputText)) }

    Column(modifier = Modifier.fillMaxWidth()) {
        BasicTextField(
            value = textFieldValue,
            onValueChange = { newValue ->
                textFieldValue = newValue
                onTextChange(newValue.text)
            },
            modifier = Modifier
                .fillMaxWidth()
                .border(1.dp, Color.LightGray, RoundedCornerShape(4.dp))
                .padding(8.dp),
            textStyle = TextStyle(fontSize = 16.sp)
        ) { innerTextField ->
            Row(modifier = Modifier.fillMaxWidth()) {
                val parsedItems = parseInput(textFieldValue.text)

                parsedItems.forEach { item ->
                    when (item) {
                        is ChatItem.Text -> Text(text = item.value, fontSize = 16.sp)
                        is ChatItem.Emoji -> EmojiImage(url = item.url)
                    }
                }
                innerTextField()
            }
        }
    }
}

@Composable
fun EmojiImage(url: String) {
    Image(
        painter = rememberAsyncImagePainter(url),
        contentDescription = null,
        modifier = Modifier.size(16.dp)
    )
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.