Pantrix UI

Secure Text Field

A text field for what is not to be read over a shoulder: a password, a PIN, a card's security code.

import com.pantrixui.components.inputs.securetextfield.PantrixSecureTextField

Examples

At its plainest

The least there is to write: a TextFieldState and a label. What is typed is drawn as dots; it cannot be cut or copied. The text is in the state: the line below reads only the length of password.text.

The eye button at the box's end shows the text and hides it again. A press on it with the mouse does not take the caret out of the field. With a keyboard, Tab stops at the button after the field, while the keyboard's "Next" key skips the button. That the text was shown is not saved: when the field is composed anew it is hidden again.

val password = rememberTextFieldState()
PantrixSecureTextField(
    state = password,
    modifier = Modifier.fillMaxWidth(),
    label = "Wi-Fi password",
    tagKey = "wifi_password"
)
PantrixText(text = "Characters typed: ${password.text.length}", color = PantrixTheme.colors.textSecondary)

Obfuscation mode and character

textObfuscationMode says how the text is drawn while it is hidden. The default, System, does as the platform does: on Android the last character typed shows for a moment where the system's own setting says so, and nowhere else does anything show. RevealLastTyped shows the last character typed for a moment on every platform, and Hidden shows it on none. Type something into the first two fields and see the difference.

textObfuscationCharacter is what is drawn in place of a hidden character: in the third field, stars instead of dots.

PantrixSecureTextField(
    state = rememberTextFieldState(),
    modifier = Modifier.fillMaxWidth(),
    label = "The last character typed shows",
    textObfuscationMode = TextObfuscationMode.RevealLastTyped,
    tagKey = "reveal_last"
)
PantrixSecureTextField(
    state = rememberTextFieldState(),
    modifier = Modifier.fillMaxWidth(),
    label = "Nothing shows",
    textObfuscationMode = TextObfuscationMode.Hidden,
    tagKey = "hidden"
)
PantrixSecureTextField(
    state = rememberTextFieldState("stars"),
    modifier = Modifier.fillMaxWidth(),
    label = "Stars instead of dots",
    textObfuscationCharacter = '*',
    tagKey = "stars"
)

Supporting text and error

supportingText stands under the box. error = true turns the border, the label, the supporting text and the caret to the danger role, and the message is announced to a screen reader as the error. Type different things into the two fields: the second one goes into error and says what is wrong.

Each field's text is in its own state; the comparison reads both from there. The first field was given only an action key: the keyboard's "Next" key does not stop at the eye button, and goes on to the second field. The rest of the keyboard was not given, so it is a password's: the password keyboard and no corrections.

val password = rememberTextFieldState()
val repeated = rememberTextFieldState()
val differs = repeated.text.isNotEmpty() && repeated.text.toString() != password.text.toString()
PantrixSecureTextField(
    state = password,
    modifier = Modifier.fillMaxWidth(),
    label = "Account password",
    supportingText = "Include letters and digits",
    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
    contentType = ContentType.NewPassword,
    tagKey = "chosen_password"
)
PantrixSecureTextField(
    state = repeated,
    modifier = Modifier.fillMaxWidth(),
    label = "Account password (again)",
    supportingText = if (differs) "The two passwords do not match" else "Type the same password once more",
    error = differs,
    contentType = ContentType.NewPassword,
    tagKey = "repeated_password"
)

Clear button, count and icon

Three things can stand side by side at the box's end, always in the same order: the clear button, the button that shows the text, and trailingIcon. The icon here says whether the password is long enough; it is not decoration, so it has a contentDescription.

The clear button of showClearButton shows while the field is not empty, and leaves the caret in the field. maxLength = 32 does not take a longer password; the count is written under the box, opposite the supporting text. placeholder shows only while the field is empty.

val password = rememberTextFieldState()
val longEnough = password.text.length >= 12
PantrixSecureTextField(
    state = password,
    modifier = Modifier.fillMaxWidth(),
    label = "Master password",
    placeholder = "It can be a sentence too",
    supportingText = "At least 12 characters",
    trailingIcon = {
        PantrixIcon(
            imageVector = if (longEnough) PantrixIcons.Success else PantrixIcons.Warning,
            contentDescription = if (longEnough) "Long enough" else "Still too short",
            size = PantrixTheme.style(TextFieldStyle).iconSize
        )
    },
    showClearButton = true,
    maxLength = 32,
    tagKey = "master_password"
)

A sign-in bound to a form

The PantrixSecureTextField that takes a field gets its value, its error and its name from its field in the form: if the field is left with the password too short, the rule's message takes the place of the supporting text. When the focus moves to the eye button the field does not count as left, so the error does not show then. The text of a field bound to a form is the value of the form's field: it does not have to be taken separately.

In a field given form, the keyboard's action key submits the form through the "Sign in" button; in the email field the "Next" key moves the focus to the password. The email field is a PantrixTextField: the two components draw the same label and the same box.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
val form = rememberFormState()
PantrixTextField(
    field = form.field("login_email", initial = "", Rules.matches(EMAIL, "Enter a valid email address")),
    modifier = Modifier.fillMaxWidth(),
    form = form,
    label = "Email",
    placeholder = "name@example.com",
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
    contentType = ContentType.EmailAddress
)
PantrixSecureTextField(
    field = form.field("login_password", initial = "", Rules.minLength(8, "The password must be at least 8 characters long")),
    modifier = Modifier.fillMaxWidth(),
    form = form,
    label = "Password",
    supportingText = "At least 8 characters",
    showClearButton = true
)
PantrixButton(
    text = "Sign in",
    onClick = {
        scope.launch { form.submit { snackbars?.show(message = "Signed in", tagKey = "signed_in") } }
    },
    form = form,
    tagKey = "sign_in"
)

private val EMAIL = Regex("""\S+@\S+\.\S+""")

A form that carries its text to a ViewModel

Each field builds its state with rememberTextFieldState, from the value the ViewModel holds at that moment; it follows the text in the state with snapshotFlow and reports each change to the ViewModel. This is how the text is taken from a field's state: the same way in a plain text field and in a secure one.

The first value in the ViewModel is read once. From then on it is one way: the text flows from the field to the ViewModel, and a change made in the ViewModel does not come back to the field. When the screen is composed anew the ViewModel is still there, and the fields start from its value.

The line below shows what the ViewModel holds: the username, and the length of the password rather than the password itself. The button reads from it too: it cannot be pressed until there is a username and a long enough password.

val viewModel = viewModel(key = "account") { AccountViewModel() }
val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
val initialUiState = remember(viewModel) { viewModel.uiState.value }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

val username = rememberTextFieldState(initialUiState.username)
LaunchedEffect(username) {
    snapshotFlow { username.text.toString() }.collectLatest { viewModel.updateUsername(it) }
}
PantrixTextField(
    state = username,
    modifier = Modifier.fillMaxWidth(),
    label = "Username",
    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
    contentType = ContentType.NewUsername,
    tagKey = "account_username"
)

val password = rememberTextFieldState(initialUiState.password)
LaunchedEffect(password) {
    snapshotFlow { password.text.toString() }.collectLatest { viewModel.updatePassword(it) }
}
PantrixSecureTextField(
    state = password,
    modifier = Modifier.fillMaxWidth(),
    label = "New password",
    supportingText = "At least 8 characters",
    contentType = ContentType.NewPassword,
    tagKey = "account_password"
)

PantrixText(
    text = "ViewModel: username \"${uiState.username}\", password length ${uiState.password.length}",
    style = PantrixTheme.typography.footnote,
    color = PantrixTheme.colors.textSecondary,
    tagKey = "account_state"
)
PantrixButton(
    text = "Create account",
    onClick = {
        scope.launch {
            snackbars?.show(message = "Account created for ${uiState.username}", tagKey = "account_created")
        }
    },
    enabled = uiState.isComplete,
    tagKey = "create_account"
)

private class AccountViewModel : ViewModel() {
    private val state = MutableStateFlow(AccountUiState())
    val uiState: StateFlow<AccountUiState> = state.asStateFlow()

    fun updateUsername(username: String) {
        state.update { it.copy(username = username) }
    }

    fun updatePassword(password: String) {
        state.update { it.copy(password = password) }
    }
}

private data class AccountUiState(
    val username: String = "",
    val password: String = ""
) {
    val isComplete: Boolean get() = username.isNotBlank() && password.length >= 8
}

A PIN that gives its text as a String

The secure field has no overload that takes value and onValueChange; for a caller that keeps its text as a String, one is built on top of the state. PinInput starts the field with value, follows the text in the state with snapshotFlow and reports each change through onValueChange. The flow is one way: value is the first value, and if it is changed from outside later, the field does not show the change. The supporting text is made from the String the caller holds, and says how many digits are missing.

This is a PIN: with showRevealButton = false it has no button to show its text, and with TextObfuscationMode.Hidden the last digit typed is not shown either. Its keyboard is the number keyboard (KeyboardType.NumberPassword); an input transformation turns down whatever is not a digit.

var pin by remember { mutableStateOf("") }
PinInput(
    value = pin,
    onValueChange = { pin = it },
    label = "PIN",
    supportingText = if (pin.length == 6) "PIN complete" else "Digits left: ${6 - pin.length}",
    modifier = Modifier.fillMaxWidth()
)

@Composable
private fun PinInput(
    value: String,
    onValueChange: (String) -> Unit,
    label: String,
    supportingText: String,
    modifier: Modifier = Modifier
) {
    val state = rememberTextFieldState(value)
    val latestOnValueChange by rememberUpdatedState(onValueChange)
    LaunchedEffect(state) {
        snapshotFlow { state.text.toString() }.collect { latestOnValueChange(it) }
    }
    PantrixSecureTextField(
        state = state,
        modifier = modifier,
        label = label,
        supportingText = supportingText,
        showRevealButton = false,
        maxLength = 6,
        textObfuscationMode = TextObfuscationMode.Hidden,
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
        inputTransformation = DigitsOnly,
        tagKey = "pin"
    )
}

private val DigitsOnly = InputTransformation {
    if (!asCharSequence().all(Char::isDigit)) revertAllChanges()
}

Disabled and read-only

A field with enabled = false takes no focus, is drawn in the theme's disabled colours and has no button to show its text.

A field with readOnly = true can be focused and its text can be shown with the button, but it cannot be changed: like a recovery code that is given once.

PantrixSecureTextField(
    state = rememberTextFieldState("unchanging"),
    modifier = Modifier.fillMaxWidth(),
    enabled = false,
    label = "Disabled",
    supportingText = "It has no button to show its text",
    tagKey = "locked_password"
)
PantrixSecureTextField(
    state = rememberTextFieldState("PX-4821-9930"),
    modifier = Modifier.fillMaxWidth(),
    readOnly = true,
    label = "Recovery code",
    supportingText = "Read-only: it can be shown but not changed",
    tagKey = "recovery_code"
)

Its own colours and style

The secure field has no style class or colour class of its own: it takes the TextFieldColors and the TextFieldStyle of PantrixTextField. A product that restyles its text fields in the theme restyles its password fields with them.

In the first field the focused border, the caret and the icon colour are the app's own purple: the eye button is drawn in the icon colour too. The colours that are not given keep their role in the theme. The second field's box is fully round: TextFieldStyle(shape = ShapeRole.Full).

val own = ownColors()
PantrixSecureTextField(
    state = rememberTextFieldState(),
    modifier = Modifier.fillMaxWidth(),
    label = "Wallet password",
    colors = TextFieldColors(focusedBorder = own.color, icon = own.color, cursor = own.color),
    tagKey = "own_colors"
)
PantrixSecureTextField(
    state = rememberTextFieldState(),
    modifier = Modifier.fillMaxWidth(),
    label = "Vault password",
    style = TextFieldStyle(shape = ShapeRole.Full),
    tagKey = "pill"
)

API

state

A text field for what is not to be read over a shoulder: a password, a PIN, a card's security code.

ParameterTypeDefaultWhat it is
stateTextFieldStatethe text and the selection. Create it with rememberTextFieldState(), and read what was typed from it: state.text where the text is used, or snapshotFlow { state.text.toString() } to follow it as it changes.
modifierModifierModifierthe modifier of the whole field: its label, its box and what is under it. fillMaxWidth() makes the box as wide as its parent.
enabledBooleantruewhether the field can be typed into. One that cannot takes no focus, is drawn in the disabled colours, has no button to show its text and says so.
readOnlyBooleanfalsewhether the text can only be read: it can still be focused, and shown by the button.
labelString?nullwhat the field is for, written above the box: the field's name for a screen reader.
placeholderString?nullshown in the box while it is empty. It is no label: it goes as soon as the user types.
supportingTextString?nullhelp under the box, in a smaller type. With error it is the message that says what to put right, which a screen reader announces as the error.
errorBooleanfalsewhether the text is wrong: the border, the label, the supporting text and the caret turn to the danger role, and a screen reader is told.
leadingIcon(@Composable () -> Unit)?nullwhat stands before the text, in the secondary text colour. A slot does not size what is put in it: PantrixIcon(size = PantrixTheme.style(TextFieldStyle).iconSize) is the platform's size.
trailingIcon(@Composable () -> Unit)?nullwhat stands after the text, after the clear button and the button that shows the text.
showClearButtonBooleanfalseshows a button that empties the field, while the field can be typed into and is not empty. It leaves the caret in the field, and is no stop for Tab.
showRevealButtonBooleantrueshows the button that shows the text and hides it again, while the field is enabled. Without it the text stays as textObfuscationMode draws it: for a text that is never to be shown, and for a caller that shows it another way.
maxLengthInt?nullthe most characters the user can enter, counted as String.length counts them. An edit that would pass the limit is turned down whole. Text set in code is not cut, and the user can still make it shorter. It has to be positive.
showCharacterCountBooleantrueshows count/maxLength under the box while maxLength is set.
textObfuscationModeTextObfuscationModeTextObfuscationMode.Systemhow the text is drawn while it is hidden. TextObfuscationMode.System does as the platform does: on Android the last character typed shows for a moment where the system's own setting says so, and nowhere else does anything show. Hidden shows nothing on any platform, and RevealLastTyped the last character on every one.
textObfuscationCharacterChar'•'what is drawn in place of a hidden character.
keyboardOptionsKeyboardOptionsKeyboardOptions.Defaultthe keyboard's type, its capitals, its corrections and its action key. What these leave unset is a password's, the password keyboard and no corrections, so that naming an action does not bring back a keyboard that learns what is typed. KeyboardType.NumberPassword is a PIN's.
onKeyboardActionKeyboardActionHandler?nullcalled for the keyboard's action key, for the action keyboardOptions names. Null keeps what the platform does: Next moves the focus on, and Done closes the keyboard.
inputTransformationInputTransformation?nullwhat is done to the text as the user types it. With maxLength, it comes first and the limit applies to what it lets through.
contentTypeContentType?nullwhat the field holds, for the platform to fill it in. Left out, the field holds ContentType.Password, as Foundation's secure field says for itself: ContentType.NewPassword is one that is being chosen.
styleTextFieldStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsTextFieldColors?nullthis call's own colours, in place of the theme's roles: the ones it sets are used, and the rest stay the theme's. A disabled field is drawn in the theme's disabled colours whatever is given.
contentDescriptionString?nullwhat a screen reader says for the field: its name where it has no label. A placeholder that says the same is then left to the eye.
tagKeyString?nullthe field's automation key: the field is SecureTextField_<tagKey>, its clear button SecureTextFieldClear_<tagKey> and the button that shows its text SecureTextFieldReveal_<tagKey>. The field reports that it took the focus, that it was cleared and that its text was shown or hidden, and never its text.
interactionSourceMutableInteractionSource?nullthe field's interactions, hoisted: its focus, its hover and its presses. It keeps a source of its own when this is null.

field

A secure text field bound to a form's field: its text, its error and its name come from field.

ParameterTypeDefaultWhat it is
fieldFormField<String>the field the text field shows and changes.
modifierModifierModifierthe modifier of the whole field.
formFormState?nullgiven, the keyboard's action key submits the form through the button registered with it. An action of Next or Previous moves the focus as it always does. A field that names no action gets ImeAction.Done, since the default action never reaches a handler. An onKeyboardAction of the caller's own takes the place of the submit.
enabledBooleantruewhether the field can be typed into.
readOnlyBooleanfalsewhether the text can only be read.
labelString?nullwhat the field is for, written above the box: the field's name for a screen reader.
placeholderString?nullshown in the box while it is empty.
supportingTextString?nullhelp under the box, while the field has no error to show there.
leadingIcon(@Composable () -> Unit)?nullwhat stands before the text.
trailingIcon(@Composable () -> Unit)?nullwhat stands after the text, after the clear button and the button that shows the text.
showClearButtonBooleanfalseshows a button that empties the field, while it can be typed into and is not empty.
showRevealButtonBooleantrueshows the button that shows the text and hides it again, while the field is enabled.
maxLengthInt?nullthe most characters the user can enter. It has to be positive.
showCharacterCountBooleantrueshows count/maxLength under the box while maxLength is set.
textObfuscationModeTextObfuscationModeTextObfuscationMode.Systemhow the text is drawn while it is hidden.
textObfuscationCharacterChar'•'what is drawn in place of a hidden character.
keyboardOptionsKeyboardOptionsKeyboardOptions.Defaultthe keyboard's type, its capitals, its corrections and its action key. What these leave unset is a password's.
onKeyboardActionKeyboardActionHandler?nullcalled for the keyboard's action key, in place of the form's submit.
inputTransformationInputTransformation?nullwhat is done to the text as the user types it.
contentTypeContentType?nullwhat the field holds, for the platform to fill it in. Left out, it is ContentType.Password.
styleTextFieldStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsTextFieldColors?nullthis call's own colours, in place of the theme's roles: the ones it sets are used, and the rest stay the theme's. A disabled field is drawn in the theme's disabled colours whatever is given.
contentDescriptionString?nullwhat a screen reader says for the field where it has no label.
interactionSourceMutableInteractionSource?nullthe field's interactions, hoisted. It keeps a source of its own when this is null.

TextFieldStyle

PantrixTextField's box: its shape, how tall and how wide it is at the least, the room around its text, its icons and its border.

ParameterTypeDefaultWhat it is
shapeShapeSpecShapeSpec.UnspecifiedThe box's shape. A fully round one, ShapeRole.Full, is a pill only while the field holds one line: a field of several lines takes ShapeRole.Large instead, since a capsule's curve runs into the first line of its text.
heightDpDp.UnspecifiedThe least the box is tall.
minWidthDpDp.UnspecifiedThe least the box is wide, which keeps an empty field from growing as the user types into it.
horizontalPaddingDpDp.UnspecifiedThe room between the box's edge and its text, where no icon is.
verticalPaddingDpDp.UnspecifiedThe room above the first line of the text and below the last.
iconPaddingDpDp.UnspecifiedThe room around an icon in its slot.
iconSizeDpDp.UnspecifiedThe size an icon in a slot should take. A slot does not size what is put in it.
borderWidthDpDp.UnspecifiedHow thick the box's border is.
focusedBorderWidthDpDp.UnspecifiedHow thick the border is while the field has the focus: the border is what shows it.
textRoleTypeRole?nullThe role of the type scale the text is typed in, and its placeholder set in: the body's, 16sp, on Android and iOS, as Material 3 and iOS type a field, and the label's, 14sp, on the desktop and the web, as a page under a pointer does. A select's box and the fields made on this style take it as well.

TextFieldColors

What PantrixTextField is painted with, for a field that does not take the theme's: a colour that is set is used, and one that is left out stays the theme's role. A product gives the colours it changes and no others:

ParameterTypeDefaultWhat it is
containerColorColor.UnspecifiedThe box's fill. The theme's surface.
borderColorColor.UnspecifiedThe box's border at rest. The theme's border.
hoveredBorderColorColor.UnspecifiedThe border under a pointer. The theme's borderHover, a notch stronger than border.
focusedBorderColorColor.UnspecifiedThe border while the field has the focus. The theme's primary.
errorColorColor.UnspecifiedWhat says the text is wrong: the border, the label, the message under the box, the caret, and the count once it is past its limit. The theme's danger.
textColorColor.UnspecifiedThe typed text. The theme's textPrimary.
labelColorColor.UnspecifiedThe label above the box. Left out, it is drawn as any text is where the field stands, since it is outside the box on whatever the field is put on: in textPrimary on the theme's own ground, and in the content colour of a fill.
placeholderColorColor.UnspecifiedThe placeholder. The theme's textSecondary.
supportingTextColorColor.UnspecifiedThe help under the box, and the count. The theme's textSecondary.
iconColorColor.UnspecifiedWhat an icon in a slot is drawn in unless it sets a tint of its own, and the clear button's cross. The theme's textSecondary.
cursorColorColor.UnspecifiedThe caret, and with it the handles and the highlight of a selection. The theme's primary.
scrollBarColorColor.UnspecifiedThe bar that shows how far the text of a box of several lines is scrolled. The theme's borderStrong: it is a mark beside the text, and the box's soft edge would not be seen there.

Accessibility