How to make a better OTP/Pin Text Field


My original attempt used seperate TextFields for each pin number. When a pin number was entered it would go to next TextField. On typing Backspace in an empty TextField it would clear the previous TextField.

The Backspace is where things get tricky. Clearing the previous TextField may cause the same TextField to clear the previous one. Even tracking the current focus TextField did not work resulting in the same behaviour. I solved this by doing a state update that would signal to move the focus forward or backward.

In the end this solution does work, but it runs into one problem, entering a pin code with double digits. Typing something like 2009, the second 0 would get eaten up. The focus has not moved on to the next TextField.

I want something more reliable.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component3
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bikeaway_kmp.app.shared.generated.resources.Res
import bikeaway_kmp.app.shared.generated.resources.unlock_btn
import com.bikeaway.bikehubs.ui.buttons.AppButtonText
import com.bikeaway.bikehubs.ui.buttons.PrimaryButton
import com.bikeaway.bikehubs.ui.theme.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.stringResource

@Composable
fun PinInputView(
    initialPin : String? = null,
    onNext: (pin: String) -> Unit,
) {
    val focusManager = LocalFocusManager.current
    val pinFieldState = rememberPinFieldState(
        initialPin = initialPin
    )
    val pinCode by pinFieldState.pinCode.collectAsState("")
    val hasPinCode by pinFieldState.hasPinCode.collectAsState(false)

    Box(
        modifier = Modifier
            .imePadding()
            .fillMaxSize()
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .wrapContentSize()
                .clickable {
                    if (pinFieldState.hasFocus) {
                        focusManager.clearFocus()
                        pinFieldState.hasFocus = false
                    }
                }
        ) {
            PinField(
                pinFieldState = pinFieldState
            )
        }

        Box(
            modifier = Modifier
                .fillMaxSize()
                .wrapContentSize(Alignment.TopStart)
        ) {
            Column {
                Text("Initial Pin: $initialPin")
            }
        }

        Box(
            modifier = Modifier
                .padding(bottom = 40.dp)
                .fillMaxSize()
                .wrapContentSize(align = Alignment.BottomCenter)
        ) {
            PrimaryButton(
                enabled = hasPinCode,
                onClick = {
                    onNext(pinCode)
                }
            ) {
                AppButtonText(stringResource(Res.string.unlock_btn))
            }
        }
    }
}

@Composable
private fun PinField(
    pinFieldState: PinFieldState = PinFieldState()
) {
    val (pin1ref, pin2ref, pin3ref, pin4ref) = FocusRequester.createRefs()
    val focusManager = LocalFocusManager.current

    LaunchedEffect(pinFieldState.changeFocusState) {
        when (pinFieldState.changeFocusState) {
            ChangeFocusState.NEXT -> {
                focusManager.moveFocus(FocusDirection.Next)
                pinFieldState.changeFocusState = ChangeFocusState.NONE
            }
            ChangeFocusState.PREVIOUS -> {
                focusManager.moveFocus(FocusDirection.Previous)
                pinFieldState.changeFocusState = ChangeFocusState.NONE
            }
            else -> Unit
        }
    }

    Row(
        horizontalArrangement = Arrangement.SpaceEvenly,
        modifier = Modifier.fillMaxWidth()
    ) {
        SinglePinField(
            pinState = pinFieldState.pinStates[0],
            onFocusNext = {
                pinFieldState.changeFocusState = ChangeFocusState.NEXT
            },
            modifier = Modifier
                .focusRequester(pin1ref)
                .focusProperties {
                    previous = pin1ref
                    next = pin2ref
                }
                .onFocusChanged {
                    if (it.hasFocus) {
                        pinFieldState.hasFocus = true
                    }
                }
        )
        SinglePinField(
            pinState = pinFieldState.pinStates[1],
            onFocusNext = {
                pinFieldState.changeFocusState = ChangeFocusState.NEXT
            },
            modifier = Modifier
                .focusRequester(pin2ref)
                .focusProperties {
                    previous = pin1ref
                    next = pin3ref
                }
                .onFocusChanged {
                    if (it.hasFocus) {
                        pinFieldState.hasFocus = true
                    }
                }
                .onPreviewKeyEvent {
                    if (
                        it.key == Key.Backspace &&
                        pinFieldState.pinStates[1].value.isEmpty()
                    ) {
                        pinFieldState.pinStates[0].value = ""
                        pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
                        return@onPreviewKeyEvent true
                    }

                    return@onPreviewKeyEvent false
                }
        )
        SinglePinField(
            pinState = pinFieldState.pinStates[2],
            onFocusNext = {
                pinFieldState.changeFocusState = ChangeFocusState.NEXT
            },
            modifier = Modifier
                .focusRequester(pin3ref)
                .focusProperties {
                    previous = pin2ref
                    next = pin4ref
                }
                .onFocusChanged {
                    if (it.hasFocus) {
                        pinFieldState.hasFocus = true
                    }
                }
                .onPreviewKeyEvent {
                    if (
                        it.key == Key.Backspace &&
                        pinFieldState.pinStates[2].value.isEmpty()
                    ) {
                        pinFieldState.pinStates[1].value = ""
                        pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
                        return@onPreviewKeyEvent true
                    }

                    return@onPreviewKeyEvent false
                }
        )
        SinglePinField(
            pinState = pinFieldState.pinStates[3],
            onFocusNext = {
                pinFieldState.changeFocusState = ChangeFocusState.NEXT
            },
            modifier = Modifier
                .focusRequester(pin4ref)
                .focusProperties {
                    previous = pin3ref
                    next = pin4ref
                }
                .onFocusChanged {
                    if (it.hasFocus) {
                        pinFieldState.hasFocus = true
                    }
                }
                .onPreviewKeyEvent {
                    if (
                        it.key == Key.Backspace &&
                        pinFieldState.pinStates[3].value.isEmpty()
                    ) {
                        pinFieldState.pinStates[2].value = ""
                        pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
                        return@onPreviewKeyEvent true
                    }

                    return@onPreviewKeyEvent false
                }
        )
    }
}

@Composable
private fun SinglePinField(
    pinState: MutableState<String>,
    modifier: Modifier = Modifier,
    onFocusNext: () -> Unit
) {
    val textFieldState = rememberTextFieldState()
    val regex = remember { Regex("[0-9]*") }
    val focusManager = LocalFocusManager.current

    LaunchedEffect(textFieldState.text) {
        pinState.value = textFieldState.text.toString()

        if (textFieldState.text.isNotEmpty()) {
            onFocusNext()
        }
    }

    LaunchedEffect(pinState.value) {
        if (pinState.value.isEmpty()) {
            textFieldState.clearText()
        } else {
            textFieldState.setTextAndPlaceCursorAtEnd(pinState.value)
        }
    }

    Box(
        modifier = Modifier.width(50.dp)
    ) {
        TextField(
            textFieldState,
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number,
                imeAction = ImeAction.Next
            ),
            lineLimits = TextFieldLineLimits.SingleLine,
            inputTransformation = InputTransformation.maxLength(1).then {
                if (!asCharSequence().matches(regex)) {
                    revertAllChanges()
                }
            },
            textStyle = MaterialTheme.typography.titleLargeEmphasized.copy(
                textAlign = TextAlign.Center
            ),
            modifier = modifier
        )
    }
}

private class PinFieldState {

    var hasFocus by mutableStateOf(false)
    var changeFocusState by mutableStateOf(ChangeFocusState.NONE)

//    var currentFocus by mutableStateOf<Int?>(null)
    val pinStates = Array(4) {
        mutableStateOf("")
    }

    private val pinStateFlow: Flow<Array<String>> = snapshotFlow {
        Array(pinStates.size) { pinStates[it].value }
    }

    val pinCode: Flow<String> = pinStateFlow.map { values ->
        values.fold(StringBuilder()) { builder, value ->
            if (value.isNotEmpty()) {
                builder.append(value)
            }
            builder
        }.toString()
    }

    val hasPinCode: Flow<Boolean> = pinStateFlow.map { values ->
        values.all { it.isNotEmpty() }
    }

//    val hasFocus: Boolean get() = currentFocus != null
//
//    fun hasFocusFor(index: Int) = hasFocus && currentFocus == index
//
//    fun clearFocus() {
//        currentFocus = null
//    }
}

private enum class ChangeFocusState {
    NEXT,
    PREVIOUS,
    NONE
}

@Composable
private fun rememberPinFieldState(
    initialPin: String? = null
): PinFieldState {
    return remember {
        val state = PinFieldState()

        initialPin?.let { initialPin ->
            initialPin.forEachIndexed { index, ch ->
                state.pinStates[index].value = ch.toString()
            }
        }

        state
    }
}

/* Preview */

@Composable
@Preview
private fun PinInputViewPreview() {
    AppTheme {
        Scaffold { innerPadding ->
            Box(
                modifier = Modifier
                    .padding(innerPadding)
            ) {
                PinInputView(
                    onNext = {},
                )
            }
        }
    }
}
0
Jun 23 at 3:25 PM
User AvatarNebulaFox
#android#compose

Accepted Answer

My solution is to scrap separate TextFields and have one BasicTextField that receives input but is not visible. Then create a Composable that shows the input as desired.

The solution is a combination of:

  • How to show PIN field with custom drawables?

  • Jetpack Compose: Hiding TextField Cursor, Cursor Handle, and Text Toolbar


import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.*
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bikeaway_kmp.app.shared.generated.resources.Res
import bikeaway_kmp.app.shared.generated.resources.unlock_btn
import com.bikeaway.bikehubs.ui.buttons.AppButtonText
import com.bikeaway.bikehubs.ui.buttons.PrimaryButton
import com.bikeaway.bikehubs.ui.misc.PADDING_BOTTOM
import com.bikeaway.bikehubs.ui.theme.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.stringResource
import kotlin.math.max
import kotlin.math.roundToInt

@Composable
fun PinInputView(
    initialPin : String? = null,
    onNext: (pin: String) -> Unit,
) {
    val focusManager = LocalFocusManager.current

    val pinFieldState = rememberPinFieldState(
        initialPin = initialPin
    )
    val hasPinCode by pinFieldState.hasPinCode.collectAsState(false)


    Box(
        modifier = Modifier
            .imePadding()
            .fillMaxSize()
            .clickable {
                if (pinFieldState.hasFocus) {
                    pinFieldState.hasFocus = false
                    focusManager.clearFocus()
                }
            }
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .wrapContentSize()

        ) {
            PinField(
                state = pinFieldState
            )
        }

        Box(
            modifier = Modifier
                .padding(bottom = PADDING_BOTTOM)
                .fillMaxSize()
                .wrapContentSize(align = Alignment.BottomCenter)
        ) {
            PrimaryButton(
                enabled = hasPinCode,
                onClick = {
                    onNext(pinFieldState.pinCode)
                }
            ) {
                AppButtonText(stringResource(Res.string.unlock_btn))
            }
        }
    }
}

@Composable
private fun PinField(
    state: PinFieldState
) {
    val textFieldState = rememberTextFieldState(
        initialText = state.pinCode ?: ""
    )

    val regex = remember { Regex("[0-9]*") }

    LaunchedEffect(textFieldState.text) {
        val text = textFieldState.text

        state.pinCode = text.toString()
        state.pinStates.forEachIndexed { index, state ->
            if (index < text.length) {
                state.value = text[index].toString()
            } else {
                state.value = ""
            }
        }
    }

    Box(
        modifier = Modifier
            .height(IntrinsicSize.Max)
    ) {
        val customTextSelectionColors = TextSelectionColors(
            handleColor = Color.Transparent,
            backgroundColor = Color.Transparent,
        )


        CompositionLocalProvider(
            LocalTextToolbar provides EmptyTextToolbar,
            LocalTextSelectionColors provides customTextSelectionColors
        ) {
            BasicTextField(
                state = textFieldState,
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.NumberPassword,
                    imeAction = ImeAction.Done
                ),
                inputTransformation = InputTransformation.maxLength(4).then {
                    if (!asCharSequence().matches(regex)) {
                        revertAllChanges()
                    }
                },
                lineLimits = TextFieldLineLimits.SingleLine,
                textStyle = TextStyle(
                    color = Color.Transparent
                ),
                cursorBrush = SolidColor(Color.Transparent),
                modifier = Modifier
                    .fillMaxSize()
                    .onFocusChanged {
                        if (it.isFocused) {
                            state.hasFocus = true
                        }
                    }

            )
            PinRowLayout  {
                state.pinStates.forEach { state ->
                    SinglePinField(
                        state = state
                    )
                }
            }
        }
    }
}

@Composable
fun SinglePinField(
    state: MutableState<String>
) {
    Box(
        modifier = Modifier
            .border(
                1.dp,
                MaterialTheme.colorScheme.onSurface,
                shape = RoundedCornerShape(12.dp)
            )
            .height(56.dp)
            .padding( horizontal = 16.dp)
            .wrapContentSize()
    ) {
        Text("0",
            style = MaterialTheme.typography.titleLarge,
            modifier = Modifier.visible(false)
        )
        Text(state.value,
            style = MaterialTheme.typography.titleLarge
        )
    }
}

@Composable
fun PinRowLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        val (allWidth, maxHeight) = placeables.fold(Pair(0, 0)) { acc, placeable ->
            Pair(
                acc.first + placeable.width,
                max(acc.second, placeable.height)
            )
        }
        val remainingWidth = constraints.maxWidth - allWidth
        val aroundSpacing = (remainingWidth.toDouble() * (1.0/((placeables.size + 3).toDouble()))).roundToInt()
//

        layout(constraints.maxWidth, maxHeight) {
            var xPosition = aroundSpacing * 2

            placeables.forEach { placeable ->
                placeable.placeRelative(xPosition, 0)
                xPosition += placeable.width + aroundSpacing
            }
        }
    }
}

private class PinFieldState(
    initialPin: String? = null
) {
    var hasFocus by mutableStateOf(false)
    val pinStates = Array(4) {
        mutableStateOf("")
    }

    var pinCode: String = initialPin ?: ""

    private val pinStateFlow: Flow<Array<String>> = snapshotFlow {
        Array(pinStates.size) { pinStates[it].value }
    }

    val hasPinCode: Flow<Boolean> = pinStateFlow.map { values ->
        values.all { it.isNotEmpty() }
    }
}

@Composable
private fun rememberPinFieldState(
    initialPin: String? = null
): PinFieldState {
    return remember {
        val state = PinFieldState(
            initialPin = initialPin
        )

        initialPin?.let { initialPin ->
            initialPin.forEachIndexed { index, ch ->
                state.pinStates[index].value = ch.toString()
            }
        }

        state
    }
}

object EmptyTextToolbar : TextToolbar {
    override fun hide() {}
    override fun showMenu(
        rect: Rect,
        onCopyRequested: (() -> Unit)?,
        onPasteRequested: (() -> Unit)?,
        onCutRequested: (() -> Unit)?,
        onSelectAllRequested: (() -> Unit)?
    ) {}

    override fun showMenu(
        rect: Rect,
        onCopyRequested: (() -> Unit)?,
        onPasteRequested: (() -> Unit)?,
        onCutRequested: (() -> Unit)?,
        onSelectAllRequested: (() -> Unit)?,
        onAutofillRequested: (() -> Unit)?
    ) {}

    override val status: TextToolbarStatus
        get() = TextToolbarStatus.Hidden
}

@Composable
@Preview
fun PinInputViewPreview() {
    AppTheme {
        Scaffold { innerPadding ->
            Box(
                modifier = Modifier.padding(innerPadding)
            ) {
                PinInputView(
                    onNext = {}
                )
            }
        }
    }
}
User AvatarNebulaFox
Jun 23 at 3:25 PM
1