Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make a always on placeholder/hint for Jetpack Compose TextField?

M3 TextField has a placeholder parameter where it shows a text when it's focused and empty. But I want to show a placeholder or a hint always as the user is typing. Something similar to search suggestions but more like Github Copilot suggestions which suggest text as you type.

current effort

Visually I achieved what I want but currently, when the cursor is moved it's not moving logically I suspect OffsetMapping is responsible for that but I can't fix it. The problem is when you try to move the cursor it moves but sometimes jumps to the end of the green text instead of moving smoothly.

class MyMaskTransformation(
    private val suggestion: String
) : VisualTransformation {

    private var suffix: String = ""

    override fun filter(text: AnnotatedString): TransformedText {
        suffix = if (suggestion.length > text.length)
            suggestion.takeLast(suggestion.length - text.length)
        else ""
        return TransformedText(
            offsetMapping = offsetMapping,
            text = buildAnnotatedString {
                withStyle(SpanStyle(Color.Unspecified)) {
                    append(text)
                }
                withStyle(SpanStyle(Color.Gray)) {
                    append(suffix)
                }
            }
        )
    }

    private val offsetMapping = object : OffsetMapping {
        override fun originalToTransformed(offset: Int): Int {
            if (offset < suggestion.length || suggestion.isEmpty()) return offset
            return suggestion.length - suffix.length
        }

        override fun transformedToOriginal(offset: Int): Int {
            offset.toString().log()
            if (offset == 0) return 0
            if (suggestion.length - suffix.length in 1 until offset)
                return suggestion.length - suffix.length
            return offset
        }
    }
}
like image 472
YaMiN Avatar asked Dec 16 '25 12:12

YaMiN


2 Answers

To add an extra view to a text field, you can use a BasicTextField with one of the standard decoration boxes.

For example, instead of OutlinedTextField you can use TextFieldDefaults.OutlinedTextFieldDecorationBox, and in innerTextField you can decorate the text field according to your requirements:

var value by remember {
    mutableStateOf("")
}
val suggestion = "Suggestion"
val interactionSource = remember { MutableInteractionSource() }
val colors = TextFieldDefaults.outlinedTextFieldColors()
val textStyle = TextStyle.Default
BasicTextField(
    value = value,
    onValueChange = { value = it },
    interactionSource = interactionSource,
    textStyle = textStyle,
    decorationBox = { innerTextField ->
        TextFieldDefaults.OutlinedTextFieldDecorationBox(
            value = value,
            innerTextField = {
                Box {
                    Text(suggestion, style = textStyle.copy(color = Color.Gray))
                    innerTextField()
                }
            },
            interactionSource = interactionSource,
            label = { Text("chat") },
            trailingIcon = { Icon(Icons.Default.Send, null) },
            colors = colors,
            enabled = true,
            singleLine = true,
            visualTransformation = VisualTransformation.None,
            border = {
                TextFieldDefaults.BorderBox(
                    enabled = true,
                    isError = false,
                    interactionSource = interactionSource,
                    colors = colors,
                )
            }
        )
    }
)

like image 184
Philip Dukhov Avatar answered Dec 19 '25 06:12

Philip Dukhov


I managed to do it by placing another text under input, it works also with spaces and makes placeholder mask under actual input invisible.

Compose version: 1.5.4, Compose Material: 1.5.4, Compose Material3: 1.1.2

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MaskableInputText(
    value: String,
    mask: String
) {
    val interactionSource = remember { MutableInteractionSource() }
    val colors = TextFieldDefaults.outlinedTextFieldColors()
    val textStyle = TextStyle.Default
    var value by remember { mutableStateOf(TextFieldValue(value)) }

    BasicTextField(
        value = value,
        onValueChange = { value = it },
        textStyle = textStyle,
        visualTransformation = { value.applyMask(mask) },
        interactionSource = interactionSource,
        decorationBox = { innerTextField ->
            TextFieldDefaults.OutlinedTextFieldDecorationBox(
                value = value.text,
                innerTextField = {
                    Box {
                        Text(
                            text = value.transformMask(mask),
                            color = Color.Gray
                        )
                        innerTextField()
                    }
                },
                interactionSource = interactionSource,
                label = { Text("chat") },
                trailingIcon = { Icon(Icons.Default.Send, null) },
                colors = colors,
                enabled = true,
                singleLine = true,
                visualTransformation = VisualTransformation.None,
                border = {
                    TextFieldDefaults.BorderBox(
                        enabled = true,
                        isError = false,
                        interactionSource = interactionSource,
                        colors = colors,
                    )
                }
            )
        }
    )
}

private fun TextFieldValue.applyMask(mask: String): TransformedText {
    val spacePositions = mask.mapIndexedNotNull { position, char -> position.takeIf { char == ' ' } }

    val maskedInput = StringBuilder(this.text).apply {
        spacePositions.forEach { position -> if (position <= length) insert(position, " ") }
    }.toString()

    return TransformedText(
        text = AnnotatedString(maskedInput),
        offsetMapping = SpacingOffsetTranslator(spacePositions)
    )
}

fun TextFieldValue.transformMask(mask: String): AnnotatedString {
    val positions = mask.mapIndexedNotNull { position, char -> position.takeIf { char == ' ' } }

    val maskedInput = StringBuilder(text).apply {
        positions.forEach { position -> if (position <= length) insert(position, " ") }
    }.toString()

    val visibleMaskText = mask.substring(maskedInput.length, mask.length)

    return buildAnnotatedString {
        withStyle(style = SpanStyle(color = Color.Transparent)) { append(maskedInput) }
        append(visibleMaskText)
    }
}

private class SpacingOffsetTranslator(private val spacePositions: List<Int>) : OffsetMapping {
    override fun originalToTransformed(offset: Int): Int {
        spacePositions.forEachIndexed { index, position -> if (offset <= position) return offset + index }
        return offset + spacePositions.size
    }

    override fun transformedToOriginal(offset: Int): Int {
        spacePositions.forEachIndexed { index, position -> if (offset <= position) return offset - index }
        return offset - spacePositions.size
    }
}
like image 40
Antonis Radz Avatar answered Dec 19 '25 07:12

Antonis Radz



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!