Creating Unique TextFields in Android with Jetpack Compose and DecorationBox

Nurattin
4 min readMay 30, 2024

--

In this article, we will explore how to use EditText and DecorationBox in Jetpack Compose to create unique text fields that can significantly enhance user interaction in mobile applications.

We will continue with a detailed look at the code and the steps necessary to implement this style.

This component can also be created using a visualTransformer and a mask. However, in this article, we will employ a different approach using decorationBox and aim to make our component adaptive.

decorationBox in Jetpack Compose is a component that allows you to add decorative elements or modify the visual presentation around other UI components, like text fields. You can use DecorationBox to draw additional graphical elements, such as lines, dots, shadows, or background images, around the content, without altering the content itself.In simple terms, decorationBox acts like a frame around your content, which you can customize any way you like

In this section, let’s explore how we can dissect our design into components which will facilitate future configurability. The design, as observed, consists of Static Text and a Text Field where users can input text, similar to an OTP input.

This means our text field component can either be static text, which we will refer to as “Mask”, or it can be an editable field, which we will call “EditableDigit”. Given that we only have these two variants, we can utilize a sealed interface to describe each element of our text field:

sealed interface PhoneNumberElement {
data class Mask(val text: String) : PhoneNumberElement
object EditableDigit : PhoneNumberElement
}

Now we can set the configuration for our field, let’s create an object that will store our templates

object FormatPatterns {
val RUS by lazy {
listOf(
PhoneNumberElement.Mask("+7"),
PhoneNumberElement.Mask("("),
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
PhoneNumberElement.Mask(")"),
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
PhoneNumberElement.Mask("-"),
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
PhoneNumberElement.Mask("-"),
PhoneNumberElement.EditableDigit,
PhoneNumberElement.EditableDigit,
)
}
}

Using this pattern, we can quickly implement and change the configurations for different locales or requirements by simply altering the list of PhoneNumberElement instances. With our format patterns defined, our next step is to create a PhoneInputTextField composable function. This function will generate a text field based on the provided configuration, which dictates how the phone number input should be structured and displayed.

@Composable
fun PhoneInputTextField(
modifier: Modifier = Modifier,
config: List<PhoneNumberElement>,
onValueChange: (String) -> Unit,
value: String,
) {
val editableDigitCount = remember {
config.filterIsInstance<PhoneNumberElement.EditableDigit>()
}
BasicTextField(
modifier = modifier,
value = value,
onValueChange = { newValue ->
if (newValue.length <= editableDigitCount.size) {
onValueChange(newValue)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
decorationBox = {
val digitIndexMap = remember(value) {
var start = 0
config.mapIndexedNotNull { index, it ->
if (it is PhoneNumberElement.EditableDigit) {
index to start++
} else null
}.toMap()
}
Row(
modifier = Modifier,
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(3.dp)
) {
config.forEachIndexed { index, phoneNumberElement ->
when (phoneNumberElement) {
PhoneNumberElement.EditableDigit -> {
digitIndexMap[index]?.let { digitIndex ->
EditableDigit(
text = value.getOrNull(digitIndex)?.toString(),
)
}
}

is PhoneNumberElement.Mask -> {
Mask(
text = phoneNumberElement.text,
)
}
}
}
}
}
)
}

In the implementation of PhoneInputTextField, there are a couple of important aspects to emphasize for clarity:

Limiting Input Length

val editableDigitCount = remember {
config.filterIsInstance<PhoneNumberElement.EditableDigit>().size
}

This count is used to limit the number of characters that can be entered by the user, ensuring that the input length never exceeds the number of available editable slots.

Mapping Editable Digits to Their Positions

The more complex part of the implementation involves this block:

val digitIndexMap = remember(value) {
var start = 0
config.mapIndexedNotNull { index, element ->
if (element is PhoneNumberElement.EditableDigit) {
index to start++
} else null
}.toMap()
}

The digitIndexMap variable is used to create a map that links the position of each EditableDigit in the config to its corresponding position in the user input (value). This map is essential for ensuring that each editable digit is correctly associated with the right character from the input string.

Already at this stage you can look at the result obtained

It turned out well, but we can do it better, let’s add animations, after all, we write in compose )

@Composable
fun EditableDigit(
modifier: Modifier = Modifier,
text: String?,
) {
Column(
modifier = modifier.width(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = text,
transitionSpec = {
(slideInVertically() + fadeIn())
.togetherWith(slideOutVertically() + fadeOut())
}) { currentText ->
Text(
modifier = Modifier.fillMaxWidth(),
text = currentText ?: " ",
fontSize = 28.sp,
)
}

Divider(
thickness = 2.dp,
color = Color.LightGray,
)
}
}

Let’s look at the result

EditableDigit with animation
EditableDigits with animation

Yes, it looks better this way, but you can easily add your own animation simply by redefining transitionSpec.

Thank you for reading this article! I wish you minimal recompositions and smoother UI experiences in all your projects. Happy coding! You can find project on GitHub at https://github.com/Nurattin/PhoneInputTextField.

--

--