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.

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

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
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With