androidkotlinandroid-jetpack-composetextfieldmaterial3

Why are some customizations for BasicTextField ignored?


I am working on implementing a customized text field using BasicTextField, but I have encountered three issues that I haven't been able to resolve.

  1. Colors not applying: Despite setting the colors, they are not reflecting in the UI as expected.

  2. VisualTransformation not modifying text: I’ve tried using a custom visualTransformation to format or mask the text input, but it’s not applying any transformations to the text.

  3. isError not changing the color: The isError flag is not updating the color of the BasicTextField as it should when there is an error, even though I've set it to trigger a color change.

I’ve tried debugging these issues, but I have been unable to figure out why the parameters are not working as intended.

/**
 * A composable function that represents a styled text field, similar to the input fields in
Spotify's UI.
 *
 * @param value The current value of the text field.
 * @param onValueChange A lambda function that handles changes to the text field's value.
 * @param modifier Modifier to customize the layout and styling of the text field.
 * @param trailingIcon An optional composable that adds an icon to the end of the text field
(e.g., a clear button or search icon).
 * @param visualTransformation A transformation applied to the text for visual effects, like
masking (e.g., password input). Defaults to no transformation.
 * @param keyboardType Specifies the type of keyboard to display (e.g., text, number, email).
Defaults to `KeyboardType.Text`.
 * @param imeAction Specifies the action button on the keyboard (e.g., Done, Next). Defaults
to `ImeAction.Done`.
 * @param readOnly Boolean that determines whether the text field is read-only. Defaults to
`false`.
 */
@SuppressLint("UnrememberedMutableInteractionSource")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpotifyTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    modifier: Modifier = Modifier,
    trailingIcon: @Composable() (() -> Unit)? = null,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    keyboardType: KeyboardType = KeyboardType.Text,
    imeAction: ImeAction = ImeAction.Done,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
    readOnly: Boolean = false,
    isError: Boolean = false,
) {
    BasicTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier
            .fillMaxWidth()
            .height(48.dp)
            //            .background(
            //                color = MaterialTheme.colorScheme.tertiary,
            //                shape = RoundedCornerShape(4.dp)
            //            )
            .padding(start = 4.dp, end = 4.dp),
        singleLine = true,
        textStyle = MaterialTheme.typography.bodyLarge,
        readOnly = readOnly,
        keyboardOptions = KeyboardOptions(
            keyboardType = keyboardType, imeAction = imeAction,
            hintLocales = LocaleList(Locale("en")),
        ),
        keyboardActions = keyboardActions,
        decorationBox = { innerTextField ->
            TextFieldDefaults.DecorationBox(
                value = value.text,
                isError = isError,
                innerTextField = { innerTextField() },
                visualTransformation = visualTransformation,
                trailingIcon = trailingIcon,
                shape = RoundedCornerShape(4.dp),
                colors = TextFieldDefaults.colors(
                    unfocusedContainerColor = MaterialTheme.colorScheme.secondary,
                    cursorColor = MaterialTheme.colorScheme.onTertiary,
                    focusedTextColor = Color.White,
                    unfocusedTextColor = Color.White,
                    focusedContainerColor = MaterialTheme.colorScheme.tertiary,
                    disabledLabelColor = MaterialTheme.colorScheme.tertiary,
                    focusedIndicatorColor = Color.Transparent,
                    unfocusedIndicatorColor = Color.Transparent,
                    errorTextColor = MaterialTheme.colorScheme.onErrorContainer, //colorResource(id = R.color.brown),
                    errorContainerColor = MaterialTheme.colorScheme.errorContainer, //colorResource(id = R.color.white_smoke),
                    errorCursorColor = MaterialTheme.colorScheme.error,
                    errorTrailingIconColor = MaterialTheme.colorScheme.error,
                    errorIndicatorColor = Color.Transparent,
                ),
                contentPadding = PaddingValues(8.dp),
                container = { },
                enabled = true,
                singleLine = true,
                interactionSource = remember { MutableInteractionSource() },
            )
        },
    )
}

/**
 * A composable function for a password input field with a toggleable visibility icon.
 * The field allows users to show or hide their password by clicking an icon.
 *
 * @param value The current value of the password field.
 * @param onValueChange A lambda function that handles changes to the password field's value.
 */
@Composable
fun SpotifyPasswordField(
    modifier: Modifier = Modifier,
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    imeAction: ImeAction = ImeAction.Done,
    isError: Boolean = false,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
    /** showPassword -> A mutable state that determines whether the password should be shown
    (true) or hidden (false).*/
    val showPassword = remember { mutableStateOf(false) }
    /** Calls the SpotifyTextField composable to create the password field. */
    SpotifyTextField(
        modifier = modifier,
        value = value,
        onValueChange = onValueChange,
        isError = isError,
        trailingIcon = {
            /** Displays a toggle button to show or hide the password.*/
            if (showPassword.value) {
                /** When the password is visible, display the "visibility" icon. */
                IconButton(onClick = { showPassword.value = false }) {
                    Icon(
                        imageVector = SpotifyIcons.Visibility,
                        contentDescription = "TAG_SHOW_PASSWORD_ICON",
                    )
                }
            } else {
                /** When the password is hidden, display the "visibility off" icon. */
                IconButton(onClick = { showPassword.value = true }) {
                    Icon(
                        imageVector = SpotifyIcons.VisibilityOff,
                        contentDescription = "TAG_HIDE_PASSWORD_ICON",
                    )
                }
            }
        },
        visualTransformation = if (showPassword.value) {
            /** No transformation applied when the password is shown. */
            VisualTransformation.None
        } else {
            /** Applies password masking transformation when the password is hidden. */
            PasswordVisualTransformation()
        },
        imeAction = imeAction,
        keyboardType = KeyboardType.Password,
        keyboardActions = keyboardActions,
    )
}

Solution

  • There are several issues with how you use the TextFieldDefaults.DecorationBox.

    1. You explicitly overwrite the container with an empty lambda. The container is used to actually layout all the various elements defined in the DecorationBox. If you don't define a layout nothing will be displayed. That is the reason why the colors, isError and the rest of the defined style and layout are ignored.

      The default container is

      Container(
          enabled = enabled,
          isError = isError,
          interactionSource = interactionSource,
          modifier = Modifier,
          colors = colors,
          shape = shape,
          focusedIndicatorLineThickness = FocusedIndicatorThickness,
          unfocusedIndicatorLineThickness = UnfocusedIndicatorThickness,
      )
      

      I don't know why you even overwrote it, but I suggest you simply remove the entire container parameter so the default is used.

    2. Although most of the colors are now displayed, the specific colors for the focused text field are still not respected. The reason is that the decoration box cannot detect the focused state. That is determined by the interactionSource. As the documentation says:

      You must first create and pass in your own remembered MutableInteractionSource instance to the BasicTextField for it to dispatch events. And then pass the same instance to this decoration box [...].

      Just add another parameter to SpotifyTextField:

      interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
      

      Then pass this interactionSource to the BasicTextField as well as to the DecorationBox. They both must use the same MutableInteractionSource.

    3. The reason the visual transformation isn't applied is that you... well, don't apply it. You only set it for the DecorationBox, but for it to be visible you need to actually set it for the BasicTextField as well. This is similar to the previous point. In general, the following parameters of DecorationBox need to be set to the same values that you also use for the BasicTextField:

      • value
      • enabled
      • singleLine
      • visualTransformation
      • interactionSource

      To enforce it, these parameters are not optional, so you have to specify them. Their documentation explicitly says that they need to be set to the same value that is used for the BasicTextField.

      You need to set them for the BasicTextField so they have the desired function.
      You need to set them for the DecorationBox so they can affect the style and layout.

    With everything put together, your call to BasicTextField would look like this:

    BasicTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier
            .fillMaxWidth()
            .height(48.dp)
            //            .background(
            //                color = MaterialTheme.colorScheme.tertiary,
            //                shape = RoundedCornerShape(4.dp)
            //            )
            .padding(start = 4.dp, end = 4.dp),
        singleLine = true,
        textStyle = MaterialTheme.typography.bodyLarge,
        readOnly = readOnly,
        keyboardOptions = KeyboardOptions(
            keyboardType = keyboardType, imeAction = imeAction,
            hintLocales = LocaleList(Locale("en")),
        ),
        keyboardActions = keyboardActions,
        visualTransformation = visualTransformation,
        interactionSource = interactionSource,
        decorationBox = { innerTextField ->
            TextFieldDefaults.DecorationBox(
                value = value.text,
                isError = isError,
                innerTextField = innerTextField,
                visualTransformation = visualTransformation,
                trailingIcon = trailingIcon,
                shape = RoundedCornerShape(4.dp),
                colors = TextFieldDefaults.colors(
                    unfocusedContainerColor = MaterialTheme.colorScheme.secondary,
                    cursorColor = MaterialTheme.colorScheme.onTertiary,
                    focusedTextColor = Color.White,
                    unfocusedTextColor = Color.White,
                    focusedContainerColor = MaterialTheme.colorScheme.tertiary,
                    disabledLabelColor = MaterialTheme.colorScheme.tertiary,
                    focusedIndicatorColor = Color.Transparent,
                    unfocusedIndicatorColor = Color.Transparent,
                    errorTextColor = MaterialTheme.colorScheme.onErrorContainer, //colorResource(id = R.color.brown),
                    errorContainerColor = MaterialTheme.colorScheme.errorContainer, //colorResource(id = R.color.white_smoke),
                    errorCursorColor = MaterialTheme.colorScheme.error,
                    errorTrailingIconColor = MaterialTheme.colorScheme.error,
                    errorIndicatorColor = Color.Transparent,
                ),
                contentPadding = PaddingValues(8.dp),
                enabled = true,
                singleLine = true,
                interactionSource = interactionSource,
            )
        },
    )