Hi Guys :wave: . I Hope one of you can help me. P...
# compose
n
Hi Guys 👋 . I Hope one of you can help me. Problem • Working with an OutlinedTextField in Compose to accept phone number input. • Need to format the phone number based on the country’s phone number pattern. • Once formatted, the cursor is no longer at the end of the phone number. Attempted Solution • Added a TextFieldValue with a selection TextRange at the end of the input. Issue with Attempted Solution • Prevents the user from moving the cursor. Request for Help • Looking for suggestions on how to allow the user to move the cursor. • Also need to ensure that if the user enters a valid number according to the country’s format, the cursor is set to the end.
t
My guess is that you format in the
onValveChange
hook?
My tests show that this hook gets fired on both character changes as well as cursor changes (at least when you're using a TextFieldValue). So if you're updating both the string and setting the cursor to the end, you're going to clobber cursor moves.
If you add some smarts to your hook though... you can disambiguate. If the current/last value has the same text as the current one, then you can assume it's just a cursor change and just update your value. If the text property has changed, only then apply your formatting and associated cursor adjustment.
n
That I didn’t know thank you. However, it doesn’t really help me. I still don’t know how to move the cursor without setting the TextRange(value.length). And if I do this then the cursor won’t move
t
I'm not following...
Copy code
onValueChange = { update -> 
  if (update.text == curentValue.text) {
    // apparently just moving the cursor, let it go through as is
    currentValue = update
  } else {
    // text changed, we'll need to format it I guess
    val formatted = format(update.text)
    currentValue = TextFieldValue(formatted, TextRange(formatted.length)
}
... or something like that
m
Hey @Nicolai I had the same issue did you solve it?
n
Hey, I honestly don’t recall this issue. I work on multiple apps and have been through a lot of features 😅 Do you have some sample code?
m
Ah, here’s the code:
Copy code
Ah ok, this is the input code:
internal sealed class PhoneNumberValidationState {
    data object Idle : PhoneNumberValidationState()
    data object Valid : PhoneNumberValidationState()
    data object Invalid : PhoneNumberValidationState()
}

internal data class PhoneNumberInputState(
    var isPhoneNumberValid: PhoneNumberValidationState,
    var isInternationalCodeMenuExpended: Boolean,
)

@Composable
fun PhoneNumberInput(
    phoneNumber: String,
    modifier: Modifier = Modifier,
    internationalCode: InternationalCode,
    onPhoneNumberChanged: (String) -> Unit,
    onCountryCodeChanged: (InternationalCode) -> Unit,
    onPhoneNumberValid: () -> Unit,
    onPhoneNumberInvalid: () -> Unit
) {
    val phoneNumberUtil: PhoneNumberUtil = koinInject()
    val coroutineScope = rememberCoroutineScope()
    val focusRequest = remember { FocusRequester() }
    val container = remember {
        object : ContainerHost<PhoneNumberInputState, Nothing> {
            override val container: Container<PhoneNumberInputState, Nothing> =
                coroutineScope.container(
                    PhoneNumberInputState(
                        isPhoneNumberValid = PhoneNumberValidationState.Idle,
                        isInternationalCodeMenuExpended = false,
                    )
                )
        }
    }
    val state by container.container.stateFlow.collectAsState()
    val interactionSource = remember { MutableInteractionSource() }
    var textFieldValue by remember {
        mutableStateOf(
            TextFieldValue(
                text = phoneNumber,
                selection = TextRange(phoneNumber.length)
            )
        )
    }
    // Focus on input when composable is initialized
    LaunchedEffect(Unit) {
        focusRequest.requestFocus()
    }

    fun onValueChange(newValue: TextFieldValue) {
        val text = newValue.text
        val unformatted = removePhoneNumberFormat(text)
        val formatted = phoneNumberUtil.formatNationalOrNull(text, internationalCode) ?: unformatted
        if (textFieldValue.text==text){
            return
        }
        Napier.d(tag = "PhoneNumberInput") {
            "PhoneNumber: ${
                phoneNumberUtil.formatNationalOrNull(
                    text,
                    internationalCode
                )
            }"
        }

        // Update the state with the formatted text and adjusted cursor position
        textFieldValue = TextFieldValue(
            text = formatted,
            selection = TextRange(unformatted.length)
        )
        onPhoneNumberChanged(unformatted)
    }

    fun onInternationalCodeChange(internationalCode: InternationalCode) {
        val dialCode = internationalCode.dialCode
        container.intent {
            reduce {
                state.copy(
                    isInternationalCodeMenuExpended = false,
                    isPhoneNumberValid = PhoneNumberValidationState.Idle
                )
            }
        }
        onPhoneNumberInvalid()
        onCountryCodeChanged(internationalCode)
        onPhoneNumberChanged("")
        textFieldValue =
            textFieldValue.copy(text = "", selection = TextRange(dialCode.length))
    }

    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column {
            Box(
                modifier = modifier
                    .border(
                        width = 2.dp,
                        color = if (state.isPhoneNumberValid == PhoneNumberValidationState.Invalid) {
                            MaterialTheme.colorScheme.error
                        } else {
                            MaterialTheme.colorScheme.primary
                        },
                        shape = MaterialTheme.shapes.medium // Adjust shape to match the outlined style
                    )
                    .padding(Spacing.S.value) // Adjust padding if needed for better alignment
            ) {
                BasicTextField(
                    value = textFieldValue,
                    onValueChange = ::onValueChange,
                    modifier = Modifier.fillMaxWidth().focusRequester(focusRequest),
                    interactionSource = interactionSource,
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done),
                    enabled = true,
                    singleLine = true,
                    textStyle = LocalTextStyle.current.copy(
                        color = MaterialTheme.colorScheme.onSurface,
                        fontSize = MaterialTheme.typography.bodyLarge.fontSize,
                        fontWeight = FontWeight.Bold
                    ),
                    decorationBox = { innerTextField ->
                        Row(
                            verticalAlignment = Alignment.CenterVertically,
                            modifier = Modifier.fillMaxWidth()
                        ) {
                            InternationalCodeMenu(
                                onInternationalCodeChange = ::onInternationalCodeChange,
                                internationalCode = internationalCode,
                                expended = state.isInternationalCodeMenuExpended,
                                onMenuClicked = {
                                    container.intent {
                                        reduce {
                                            state.copy(
                                                isInternationalCodeMenuExpended = !state.isInternationalCodeMenuExpended
                                            )
                                        }
                                    }
                                }
                            )
                            Spacer(modifier = Modifier.width(HSpacing.S.value)) // Spacing between prefix and input
                            Text(
                                text = internationalCode.dialCode,
                                style = TextStyle(
                                    color = MaterialTheme.colorScheme.onSurface,
                                    fontSize = MaterialTheme.typography.bodyLarge.fontSize,
                                    fontWeight = FontWeight.Bold
                                )
                            )
                            Spacer(modifier = Modifier.width(HSpacing.S.value)) // Spacing between prefix and input
                            innerTextField() // The actual text field content
                        }
                    }
                )
            }
            AnimatedVisibility(state.isPhoneNumberValid == PhoneNumberValidationState.Invalid) {
                Spacer(Modifier.padding(Spacing.XS.value))
                Text(
                    text = stringResource(Res.string.invalidMobileNumber),
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyMedium,
                )
            }
        }
    }
}
n
Maybe you can condense it into a preview function I can take a look at and avoid the koin and data classes not shown here?
Just so I can run it easily, see the issue and investigate a bit?
m
Sure
Copy code
@Composable
fun PhoneNumberInput(
    phoneNumber: String,
    modifier: Modifier = Modifier,
    internationalCode: InternationalCode,
    onPhoneNumberChanged: (String) -> Unit,
) {
    var textFieldValue by remember {
        mutableStateOf(
            TextFieldValue(
                text = phoneNumber,
                selection = TextRange(phoneNumber.length)
            )
        )
    }
  

    fun onValueChange(newValue: TextFieldValue) {
        val text = newValue.text
        val unformatted = removePhoneNumberFormat(text)
        val formatted = phoneNumberUtil.formatNationalOrNull(text, internationalCode) ?: unformatted
        if (textFieldValue.text==text){
            return
        }

        // Update the state with the formatted text and adjusted cursor position
        textFieldValue = TextFieldValue(
            text = formatted,
            selection = TextRange(unformatted.length)
        )
        onPhoneNumberChanged(unformatted)
    }


    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column {
            Box {
                BasicTextField(
                    value = textFieldValue,
                    onValueChange = ::onValueChange,
                    modifier = Modifier.fillMaxWidth().focusRequester(focusRequest),
                    interactionSource = interactionSource,
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done),
                    enabled = true,
                    singleLine = true,
                )
            }
        }
    }
}
internal fun removePhoneNumberFormat(value: String): String =
    value.replace(" ", "")
        .replace("-", "")
        .replace(")", "")
        .replace("(", "")
I am using libphonenumber for formatting phone number this is phoneNumberUtil
Copy code
actual class PhoneNumberUtil {
    private val phoneNumberUtil = getInstance()

    actual fun formatInternationalOrNull(phoneNumber: String, internationalCode: InternationalCode): String? = try {
        val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
        phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
    } catch (e: Exception) {
        null
    }

    actual fun isValidPhoneNumber(phoneNumber: String, internationalCode: InternationalCode): Boolean = try {
        val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
        phoneNumberUtil.isValidNumber(number)
    } catch (e: Exception) {
        false
    }

    actual fun parseOrNull(phoneNumber: String, internationalCode: InternationalCode): String? = try {
        val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
        phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)
    } catch (e: Exception) {
        null
    }

    actual fun formatNationalOrNull(
        phoneNumber: String,
        internationalCode: InternationalCode
    ): String? = try {
        val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
        phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)
    } catch (e: Exception) {
        null
    }
}
n
It would be a lot easier if you just provided a working sample with a preview with no unknown data classes like InternationalCode.
m
InternationalCode is just an enum that has US UK.. etc you can replace dialCode with +1 isoCode with US
n
So the only issue is that you can’t move the cursor right?
Something like this or am I misunderstanding you?
m
@Nicolai Yes this is exactly what happened
n
I’m not following. Is this what you want? Or is there an issue I don’t see?
m
I mean this is the issue, I need to keep the curser position when the input value is changed after formatting
n
That seems to work?
m
Yes
I need the same behaviour in the video I shared
n
Oh I thought this was yours
Let me try again
🙌 1
m
@Nicolai YES!
Exactly what I need
Can you share the implementation please?
n
Absolutely 🙂 Hope it helps!
Copy code
@Composable
fun PhoneNumberInput(
    phoneNumber: String,
    modifier: Modifier = Modifier,
    onPhoneNumberChanged: (String) -> Unit,
) {
    val context = LocalContext.current
    val phoneNumberUtil = remember { PhoneNumberUtil.createInstance(context) }
    var textFieldValue by remember { mutableStateOf(TextFieldValue(phoneNumber)) }
    val focusRequest = remember { FocusRequester() }

    // Handle value change and phone number formatting
    fun onValueChange(newValue: TextFieldValue) {
        val unformatted = removePhoneNumberFormat(newValue.text)

        try {
            val phoneNumberProto = phoneNumberUtil.parse(unformatted, "US")
            val formatted = phoneNumberUtil.format(
                phoneNumberProto,
                PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL
            )

            // Update cursor position after formatting
            val cursorPosition = calculateCursorPosition(newValue.text, formatted, newValue.selection.start)

            textFieldValue = TextFieldValue(
                text = formatted,
                selection = TextRange(cursorPosition)
            )
            onPhoneNumberChanged(unformatted)
        } catch (e: Exception) {
            // Fallback to raw input if formatting fails
            textFieldValue = newValue
            onPhoneNumberChanged(newValue.text)
        }
    }

    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column {
            Box {
                TextField(
                    value = textFieldValue,
                    onValueChange = {
                        onValueChange(it)
                    },
                    modifier = Modifier
                        .fillMaxWidth()
                        .focusRequester(focusRequest, true),
                    keyboardOptions = KeyboardOptions(
                        keyboardType = KeyboardType.Phone,
                        imeAction = ImeAction.Done
                    ),
                    singleLine = true,
                )
            }
        }
    }
}

private fun calculateCursorPosition(
    originalText: String,
    formattedText: String,
    cursorPosition: Int
): Int {
    // If the cursor is at the end of the original text, move to the end of the formatted text
    if (cursorPosition >= originalText.length) return formattedText.length

    // Calculate how many characters from the original text are present in the formatted text
    var formattedIndex = 0
    var unformattedIndex = 0

    // Walk through both the original and formatted texts to map the cursor position
    while (unformattedIndex < cursorPosition && formattedIndex < formattedText.length) {
        if (originalText[unformattedIndex] != formattedText[formattedIndex]) {
            // Skip non-alphanumeric characters in the formatted text (i.e., formatting symbols)
            formattedIndex++
        } else {
            unformattedIndex++
            formattedIndex++
        }
    }

    // Ensure the cursor position is valid and within the bounds of the formatted text
    return formattedIndex.coerceIn(0, formattedText.length)
}

internal fun removePhoneNumberFormat(value: String): String =
    value.replace(" ", "")
        .replace("-", "")
        .replace(")", "")
        .replace("(", "")

@Preview
@Composable
private fun PhoneNumberInputPreview() {
    var phoneNumber by remember { mutableStateOf("") }

    Column (
        Modifier
            .fillMaxSize()
            .padding(top = 200.dp)
    ) {
        PhoneNumberInput(
            phoneNumber = phoneNumber,
            onPhoneNumberChanged = { phoneNumber = it }
        )
    }
}
m
@Nicolai Thank you so much for the help, I will try it and let you know
n
You're welcome!
@Moref did you manage to get it to work mate?
m
@Nicolai actually no, it did not work as expected, but I found another solution Using
VisualTransformation
here’s the code:
Copy code
@Composable
fun PhoneNumberInput(
    label: String,
    phoneNumber: String,
    onPhoneNumberChange: (String) -> Unit,
    countryCode: String = "US",
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = phoneNumber,
        onValueChange = { newValue -> onPhoneNumberChange(newValue) },
        label = { Text(label) },
        visualTransformation = PhoneNumberVisualTransformation(countryCode),
        singleLine = true,
        prefix = {
            Text(countryCode)
        },
        modifier = modifier.fillMaxWidth()
    )
}



class PhoneNumberVisualTransformation(
    private val countryCode: String
) : VisualTransformation {
    private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()

    override fun filter(text: AnnotatedString): TransformedText {
        val rawInput = text.text
        var formattedNumber = rawInput
        val offsetMap = mutableListOf<Pair<Int, Int>>()

        try {
            // Parse and format the phone number
            val parsedNumber = phoneNumberUtil.parse(rawInput, countryCode)
            formattedNumber = phoneNumberUtil.format(
                parsedNumber,
                PhoneNumberUtil.PhoneNumberFormat.NATIONAL
            )

            // Generate offset mapping
            var originalIndex = 0
            for (i in formattedNumber.indices) {
                if (originalIndex < rawInput.length && formattedNumber[i] == rawInput[originalIndex]) {
                    offsetMap.add(originalIndex to i)
                    originalIndex++
                } else {
                    offsetMap.add(originalIndex to i)
                }
            }
            while (originalIndex < rawInput.length) {
                offsetMap.add(originalIndex to formattedNumber.length)
                originalIndex++
            }
        } catch (e: Exception) {
            // Fallback to raw input if formatting fails
            formattedNumber = rawInput
            for (i in rawInput.indices) {
                offsetMap.add(i to i)
            }
        }

        return TransformedText(
            AnnotatedString(formattedNumber),
            object : OffsetMapping {
                override fun originalToTransformed(offset: Int): Int {
                    return offsetMap.find { it.first == offset }?.second ?: formattedNumber.length
                }

                override fun transformedToOriginal(offset: Int): Int {
                    return offsetMap.findLast { it.second == offset }?.first ?: rawInput.length
                }
            }
        )
    }
}
🙌 1