Migrating Forms to Android Jetpack Compose

Moshe Waisberg
Israeli Tech Radar
Published in
7 min readJan 31, 2023

In this follow-up tutorial, we will discuss migrating an Android XML form to Android Jetpack Compose.

Let’s make this migration to Compose easier by replacing just a single piece of old XML layout. The main idea is to replace the XML form layout with its corresponding Composable and make sure that the Composable is identical as much as possible to its predecessor, not just visually but also in terms of behaviour.

The main steps in the migration are:

  1. Create the Composable file.
  2. Add a preview in the file.
  3. Add a Composable container in the original layout file that overlays on top of the layout that is being replaced.
  4. Test the twin layout on a device.
  5. Use the Composable directly in place of the layout.
  6. Remove the XML file(s).

A quick reminder about some of the corresponding XML-Composable pairs that we will be replacing:

  • ButtonButton
  • EditTextTextField
  • FrameLayoutBox
  • ImageViewImage
  • LinearLayout (vertical orientation) → Column
  • LinearLayout (horizontal orientation) → Row
  • TextViewText
  • TextInputEditTextOutlinedTextField

Let’s work through a simple example, assuming that your project already has all of the necessary Jetpack Compose dependencies…

Suppose we have a basic application that shows a login form. Usually the app will have an XML layout for the main activity. Then that XML will have a container for fragments. The login fragment will itself have an XML layout.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="24dp">

<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:endIconDrawable="@drawable/ic_person"
app:endIconMode="custom">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_login"
android:inputType="textEmailAddress"
android:maxLines="1"
android:singleLine="true" />

</com.google.android.material.textfield.TextInputLayout>

<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:endIconMode="password_toggle">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_password"
android:imeActionId="109"
android:imeActionLabel="@string/action_sign_in"
android:imeOptions="actionGo"
android:inputType="textPassword"
android:maxLength="30"
android:maxLines="1"
android:singleLine="true" />

</com.google.android.material.textfield.TextInputLayout>

<Button
android:id="@+id/actionSignIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_sign_in"
app:drawableEndCompat="@drawable/ic_lock_open">

<requestFocus />
</Button>

</LinearLayout>
A simple login form

The next step is to replace the layout with a Composable.

When migrating each layout, it would be nice to see just how accurate the replacement is, so let’s overlay the Composable on top of the XML:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="24dp">

<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:endIconDrawable="@drawable/ic_person"
app:endIconMode="custom">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_login"
android:inputType="textEmailAddress"
android:maxLines="1"
android:singleLine="true" />

</com.google.android.material.textfield.TextInputLayout>

<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:endIconMode="password_toggle">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_password"
android:imeActionId="109"
android:imeActionLabel="@string/action_sign_in"
android:imeOptions="actionGo"
android:inputType="textPassword"
android:maxLength="30"
android:maxLines="1"
android:singleLine="true" />

</com.google.android.material.textfield.TextInputLayout>

<Button
android:id="@+id/actionSignIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_sign_in"
app:drawableEndCompat="@drawable/ic_lock_open">

<requestFocus />
</Button>

</LinearLayout>

<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.75" />
</FrameLayout>

Next, create the Composable…

@Composable
fun LoginForm(user: User, onConfirmClick: (() -> Unit)) {
val isPasswordVisible = remember { mutableStateOf(false) }

Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.prompt_login),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = user.name,
trailingIcon = {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(
id = R.drawable.ic_lock_open
)
),
contentDescription = ""
)
},
singleLine = true,
onValueChange = { value: String -> TODO("update the view state") },
textStyle = MaterialTheme.typography.body1,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii)
)
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.prompt_password),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = user.password,
trailingIcon = {
IconButton(onClick = { isPasswordVisible.value = !isPasswordVisible.value }) {
Icon(
imageVector = if (isPasswordVisible.value) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff,
contentDescription = "Visibility"
)
}
},
singleLine = true,
onValueChange = { value: String -> TODO("update the view state") },
textStyle = MaterialTheme.typography.body1,
visualTransformation = if (isPasswordVisible.value) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Button(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
onClick = onConfirmClick
) {
Text(text = stringResource(id = R.string.action_sign_in))
}
}
}
Preview of the Composable version
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

binding.loginInput.setText(viewModel.user.name)
binding.passwordInput.setText(viewModel.user.password)

binding.loginInput.doAfterTextChanged {
viewModel.user.name = it.toString()
}
binding.passwordInput.doAfterTextChanged {
viewModel.user.password = it.toString()
}

binding.composeView.setContent {
AppTheme {
LoginForm(user = viewModel.user, onConfirmClick = viewModel::submit)
}
}
}

You can also wrap the form inside of a Card or Dialog.
Now, the first problem is that the UI isn’t 100% pixel-perfect, but it’s good enough. Rip out the old XML binding stuff.

Here is the new form layout XML:

<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

The other problem with this is that the form does not interact. Composables are optimized for fast rendering and are thus essentially “read only”. They need to remember some state for the next render. The text fields need to update themselves and their view state in the ViewModel. Let’s replace the TODOs in the Composable above:

@Composable
fun LoginForm(user: User, onConfirmClick: (() -> Unit)) {
val userValue = remember { mutableStateOf(user.name) }
val passwordValue = remember { mutableStateOf(user.password) }
val isPasswordVisible = remember { mutableStateOf(false) }

Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.prompt_login),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = userValue.value,
trailingIcon = {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(
id = R.drawable.ic_lock_open
)
),
contentDescription = ""
)
},
singleLine = true,
onValueChange = { value: String ->
user.name = value
userValue.value = value
},
textStyle = MaterialTheme.typography.body1,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii)
)
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.prompt_password),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = passwordValue.value,
trailingIcon = {
IconButton(onClick = { isPasswordVisible.value = !isPasswordVisible.value }) {
Icon(
imageVector = if (isPasswordVisible.value) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff,
contentDescription = "Visibility"
)
}
},
singleLine = true,
onValueChange = { value: String ->
user.password = value
passwordValue.value = value
},
textStyle = MaterialTheme.typography.body1,
visualTransformation = if (isPasswordVisible.value) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Button(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
onClick = onConfirmClick
) {
Text(text = stringResource(id = R.string.action_sign_in))
}
}
}

Another problem, is that the Composable edit text does not display an error message. However, it does have an error state. So we need to add an error message label.

XML form with an error message in the field
@Composable
fun LoginForm(
user: User,
errorLogin: String? = null,
errorPassword: String? = null,
onConfirmClick: (() -> Unit)
) {
val userValue = remember { mutableStateOf(user.name) }
val passwordValue = remember { mutableStateOf(user.password) }
val isPasswordVisible = remember { mutableStateOf(false) }
val isLoginError = !errorLogin.isNullOrEmpty()
val isPasswordError = !errorPassword.isNullOrEmpty()

Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.prompt_login),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = userValue.value,
trailingIcon = {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(
id = R.drawable.ic_lock_open
)
), contentDescription = ""
)
},
singleLine = true,
onValueChange = { value: String ->
user.name = value
userValue.value = value
},
textStyle = MaterialTheme.typography.body1,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
isError = isLoginError
)
if (isLoginError) {
Text(
text = errorLogin ?: "", color = MaterialTheme.colors.error
)
}
OutlinedTextField(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
label = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.prompt_password),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium
)
},
value = passwordValue.value,
trailingIcon = {
IconButton(onClick = { isPasswordVisible.value = !isPasswordVisible.value }) {
Icon(
imageVector = if (isPasswordVisible.value) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff,
contentDescription = "Visibility"
)
}
},
singleLine = true,
onValueChange = { value: String ->
user.password = value
passwordValue.value = value
},
textStyle = MaterialTheme.typography.body1,
visualTransformation = if (isPasswordVisible.value) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = isPasswordError
)
if (isPasswordError) {
Text(
text = errorPassword ?: "", color = MaterialTheme.colors.error
)
}
Button(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(), onClick = onConfirmClick
) {
Text(text = stringResource(id = R.string.action_sign_in))
}
}
}
Composable form with an error message under the field

The next steps (which I will leave to you as an exercise) are:

  1. Put all the parameters in a single view state argument
  2. Change each parameter to a Flowable that will be collected inside the Composable
interface LoginViewState {
val user: Flow<User>
val errorLogin: Flow<String?>
val errorPassword: Flow<String?>
val onConfirmClick: (() -> Unit)
}

@Composable
fun LoginForm(viewState: LoginViewState) {
val userState = viewState.user.collectAsState(initial = User("", ""))
val errorLoginState = viewState.errorLogin.collectAsState(initial = "")
val errorPasswordState = viewState.errorPassword.collectAsState(initial = "")

val user = userState.value
val errorLogin = errorLoginState.value
val errorPassword = errorPasswordState.value

val userValue = remember { mutableStateOf(user.name) }
val passwordValue = remember { mutableStateOf(user.password) }
val isPasswordVisible = remember { mutableStateOf(false) }
val isLoginError = !errorLogin.isNullOrEmpty()
val isPasswordError = !errorPassword.isNullOrEmpty()
...
}

Now whenever the view state changes, the UI will be updated.

So there you go, another relatively painless episode of migrating to Compose.

--

--