Build a form with Jetpack Compose, a better use of the state

Lunabee Studio Dev
7 min readMay 12, 2023

The goal of this article is to show you a way to implement a form with inputs. We will present a simple method to handle the state of the form and make sure it survives configuration changes

This is our objective👇

Let’s implement the UI of the screen : 📱

We can take our keyboard and start to write our compose screen ! 🧑‍💻

@Composable
fun LoginScreen() {

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 24.dp)
.imePadding()
) {
Text(
text = "Hikes",
style = HikesTheme.typography.header
)

Image(
painter = painterResource(id = R.drawable.hiking),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)


OutlinedTextField(
value = "" ,
onValueChange = {},
placeholder = { Text(text = "Email") },
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = "" ,
onValueChange = {},
placeholder = { Text(text = "Password") },
modifier = Modifier.fillMaxWidth()
)

Button(onClick = { }) {
Text(
text = "Login",
style = HikesTheme.typography.action
)
}

}
}

The code above allows us to display the login screen required. But if you try to use the text inputs, you will notice that there is something wrong… 🤔

The textfields do not register our inputs, but don’t worry there is a perfectly rational explication to this phenomenon 😉

OutlinedTextField(
value = "" 👈 ⚠️,
onValueChange = {}, 👈 ⚠️
placeholder = { Text(text = "Email") },
modifier = Modifier.fillMaxWidth()
)

Our current implementation is missing a key element : the state !

By making the UI react to a state change, Compose introduced a new way of building screens. When thinking in compose the state is at the heart of our reflexion. If we do not update the state, our UI will never change.

Naive state

Compose is declarative, it means that in order to update themselves, our composables need to observe for changes in a state.

One of the easy way to create a state is to use a remember associated with a MutableState

var email: String by remember { mutableStateOf("") }

We can now use that email variable in our textfield 👇

OutlinedTextField(
value = email,
onValueChange = { value -> email = value },
placeholder = {
Text(text = "Email")
},
modifier = Modifier.fillMaxWidth()
)

As you can see, we pass the email as a value to our textfield, we also update it using a lambda in the onValueChange parameter.

By doing that the user can now enter its email and password 🎉

However, our current implementation will allow us to remember the values of the email and password as long our entire screen is not recomposed, and it is exactly what happens during a configuration change :

If the user switch from light to dark mode or perform a device rotation, our state is not remembered which can be very frustrating. Hopefully for she/him, there is something that will fix our issues.

RememberSaveable to the rescue

As we’ve seen, using remember will not retain state across configuration change, however, rememberSaveable will save the value in a Bundle that will survive theses config changes.

For that, we just need to replace our remember by rememberSaveable

var email: String by rememberSaveable { mutableStateOf("") }

Our state now survives recomposition 🥳

But what if our state needs to hold a lot of information (like a big form for instance) or need to save custom data ? Having a lot of rememberSaveable declaration doesn’t seem very optimal and will alter the clarity of the code.

Implementing a custom composable state

The advantage of creating a custom state is that we will have a state that really matches our need.

For our screen we need to save the email and the password (for now 😉)

class LoginScreenComposableState {

var email: String by mutableStateOf("")
private set
var password: String by mutableStateOf("")
private set

fun updateEmail(newValue: String) {
email = newValue
}

fun updatePassword(newValue: String) {
password = newValue
}
}

Now that we have defined what data our custom state will hold, we need to create a custom Saver for it. To put in simple terms, the role of this saver is to describe how the LoginScreenComposableState can be converted into something Saveable. We will need to provide a map of Key,Value that represent our state (save), and a way to build our State from that map (restore).

companion object {
val Saver: Saver<LoginScreenComposableState, Any> = run {
mapSaver(
save = { state ->
mapOf(
EmailKey to state.email,
PasswordKey to state.password,
)
},
restore = { restoredMap ->
LoginScreenComposableState().apply {
email = restoredMap[EmailKey] as String
password = restoredMap[PasswordKey] as String
}
}
)
}

private const val EmailKey: String = "EmailKey"
private const val PasswordKey: String = "PasswordKey"
}

The only thing missing is a way to instantiate this state from our composable.

@Composable
fun rememberLoginScreenComposableState(): LoginScreenComposableState =
rememberSaveable(saver = LoginScreenComposableState.Saver) {
LoginScreenComposableState()
}

If we wrap-up everything we have this LoginScreenComposableState class 👇

class LoginScreenComposableState {

var email: String by mutableStateOf("")
private set

var password: String by mutableStateOf("")
private set


fun updateEmail(newValue: String) {
email = newValue
}

fun updatePassword(newValue: String) {
password = newValue
}

companion object {
val Saver: Saver<LoginScreenComposableState, Any> = run {
mapSaver(
save = { state ->
mapOf(
EmailKey to state.email,
PasswordKey to state.password,
)
},
restore = { restoredMap ->
LoginScreenComposableState().apply {
email = restoredMap[EmailKey] as String
password = restoredMap[PasswordKey] as String
}
}
)
}

private const val EmailKey: String = "EmailKey"
private const val PasswordKey: String = "PasswordKey"
}

}

@Composable
fun rememberLoginScreenComposableState(): LoginScreenComposableState =
rememberSaveable(saver = LoginScreenComposableState.Saver) {
LoginScreenComposableState()
}

In our composable we can now instantiate our state :

val composableState: LoginScreenComposableState = 
rememberLoginScreenComposableState()

And use it in our texts fields :

OutlinedTextField(
value = composableState.email,
onValueChange = composableState::updateEmail,
placeholder = { Text(text = "Email") },
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = composableState.password,
onValueChange = composableState::updatePassword,
placeholder = { Text(text = "Password") },
modifier = Modifier.fillMaxWidth()
)

Add logic to our state

Currently our state and our screen are quite simple, but, if we want a real login screen we need to add several things :

  • A way to hide/show the password
  • A form validation that will enable or disable the “Login” button (if one of the field is empty)

The “naive” way to do it will be to do it in our composable. But it will impact the code clarity.

So, in order to keep our compose code clean, we will add the logic directly in our state

class LoginScreenComposableState {

var email: String by mutableStateOf("")
private set

var password: String by mutableStateOf("")
private set

var hidePassword: Boolean by mutableStateOf(true) 👈 // New
private set

val passwordVisualTransformation: VisualTransformation 👈 // New
get() = if (hidePassword) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}

val passwordTrailingIcon: ImageVector 👈 // New
get() = if (hidePassword) {
Icons.Default.Visibility
} else {
Icons.Default.VisibilityOff
}

val isFormValid: Boolean 👈 // New
get() = email.isNotEmpty() && password.isNotEmpty()

fun updateEmail(newValue: String) {
email = newValue
}

fun updatePassword(newValue: String) {
password = newValue
}

👇 // New
fun togglePasswordVisibility() {
hidePassword = !hidePassword
}

companion object {
val Saver: Saver<LoginScreenComposableState, Any> = run {
mapSaver(
save = { state ->
mapOf(
EmailKey to state.email,
PasswordKey to state.password,
HidePasswordKey to state.hidePassword 👈 // New
)
},
restore = { restoredMap ->
LoginScreenComposableState().apply {
email = restoredMap[EmailKey] as String
password = restoredMap[PasswordKey] as String
hidePassword = restoredMap[HidePasswordKey] as Boolean 👈 // New
}
}
)
}

private const val EmailKey: String = "EmailKey"
private const val PasswordKey: String = "PasswordKey"
private const val HidePasswordKey: String = "HildePasswordKey" 👈 // New
}

}

We can now simply call the values from our state in our composable :

OutlinedTextField(
value = composableState.email,
onValueChange = composableState::updateEmail,
placeholder = { Text(text = "Email") },
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = composableState.password,
onValueChange = composableState::updatePassword,
placeholder = { Text(text = "Password") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = composableState.passwordVisualTransformation,
trailingIcon = {
IconButton(composableState::togglePasswordVisibility) {
Icon(
imageVector = composableState.passwordTrailingIcon,
contentDescription = "Change password visibility"
)
}
}
)

We now have a great looking login screen 🥳👇

Now you know how to use the state in order to create a form screen 🤩🎯

Keep in mind that we’ve only describe a simple use case, but if you need to develop a more complex screen, this article can be a good entry point 😉

Florian N’Gbala

--

--

Lunabee Studio Dev

This is Lunabee Studio’s account, on which we share content on various topics, including of course Mobile Apps’ creation.