Search code examples
androidkotlinandroid-jetpack-composeandroid-jetpack-compose-material3android-compose-textfield

Label positioning issue in BasicTextField on focus jetpack compose


I'm encountering an issue with the label in BasicTextField in Jetpack Compose. When the text field gains focus (either by clicking or tapping), the label's height behaves strangely and does not transition or move correctly unless I start typing text into the field.

MainActivity.kt

package com.example.baisctextfieldexample

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicSecureTextField
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.example.baisctextfieldexample.ui.theme.BaiscTextFieldExampleTheme

class MainActivity : ComponentActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            BaiscTextFieldExampleTheme {
                val stateOne = rememberTextFieldState()
                val stateOTwo = rememberTextFieldState()
                BasicTextFieldExamples(stateOne, stateOTwo)
            }
        }
    }

    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun BasicTextFieldExamples(stateOne: TextFieldState, stateOTwo: TextFieldState) {
        Column(
            Modifier
                .fillMaxSize()
                .padding(50.dp)
        ) {
            BasicTextField(
                state = stateOne,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(56.dp),
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Email,
                    imeAction = ImeAction.Next
                ),
                decorator = {
                    TextFieldDefaults.DecorationBox(
                        value = stateOne.text.toString(),
                        innerTextField = it,
                        enabled = true,
                        singleLine = true,
                        visualTransformation = VisualTransformation.None,
                        label = {
                            Text("Username")
                        },
                        placeholder = {
                            Text("Username Placeholder")
                        },
                        interactionSource = remember { MutableInteractionSource() }
                    )
                }
            )
            BasicSecureTextField(
                state = stateOTwo,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(56.dp),
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                decorator = {
                    TextFieldDefaults.DecorationBox(
                        value = stateOTwo.text.toString(),
                        innerTextField = it,
                        enabled = true,
                        singleLine = true,
                        visualTransformation = VisualTransformation.None,
                        label = {
                            Text("Password")
                        },
                        placeholder = {
                            Text("Password Placeholder")
                        },
                        interactionSource = remember { MutableInteractionSource() }
                    )
                }
            )
        }
    }
}

You can find the complete code implementation in my GitHub repository

libs.versions.toml

[versions]
agp = "8.7.3"
kotlin = "2.1.0"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"
composeBom = "2025.01.00"
material3Release = "1.4.0-alpha06"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Is this a bug with BasicTextField, or is there a workaround to fix the label's behavior so it transitions properly on focus, even when no text is entered?

Any insights or suggestions would be greatly appreciated!

You can watch the problem from here.


Solution

  • The problem is in how you use the TextFieldDefaults.DecorationBox. The following parameters are mandatory and must be set to the same value as the respective parameters of the BasicTextField:

    • value
    • enabled
    • singleLine
    • visualTransformation
    • interactionSource

    The culprit here is interactionSource. The documentation explicitly explains it:

    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 to observe Interactions and customize the appearance / behavior of this text field in different states.

    You set it to a new MutableInteractionSource where you leave the BasicTextField's interactionSource at its default value null. This way the DecorationBox cannot detect when the BasicTextField gains focus and cannot properly move the label aside.

    Just create a new variable

    val interactionSourceOne = remember { MutableInteractionSource() }
    

    and use it for both, the BasicTextField and TextFieldDefaults.DecorationBox. You need to repeat this for the second text field with a new variable named interactionSourceTwo.


    Note that BasicTextFields default to multiline, not singleline, so you have another mismatch in the DecorationBox's parameters. Either explicitly set lineLimits = TextFieldLineLimits.SingleLine for the BasicTextField or set singleLine = false for the DecorationBox.

    BasicSecureTextFields only allow single line input, so you do not need to change anything here.


    The BasicTextFields that accept a TextFieldState are pretty new and not all APIs are already updated to use these. Especially the preconfigured Material 3 components are still missing. They will be available in the next version of Compose, though, and they are already present in the current alpha versions. If you are inclined to move from your stable Compose BOM to the alpha Compose BOM (just replace compose-bom in your version catalog with compose-bom-alpha) until the stable release of the Material 3 libraries in versioin 1.4.0 you can replace your BasicTextField and BasicSecureTextField with the styled M3 versions TextField and SecureTextField that provide the labels and placeholders out-of-the-box. You wouldn't need to meddle with the DecorationBox anymore which is less error prone and would actually have prevented the problem from the question.

    This would even become necessary when you want to use an OutputTransformation on the BasicTextField, since the DecorationBox only allows an incompatible VisualTransformation. You would at least need to replace the TextFieldDefaults.DecorationBox with TextFieldDefaults.decorator(), which is currently also only available in the alpha BOM.