Android Compose, Crafting a Unique Focus Experience on Login Form
This article will guide you through the process of managing focus and the soft keyboard in Android. To demonstrate these concepts, we will walk you through a practical example involving a login form.
The next image on the left illustrates what can be done, while the image on the right represents what we will accomplish in this tutorial.
Please note that this article is focused solely on the efficient management of focus and keyboard interactions, without delving into theming, custom drawing, or click interception. While the approach we’ll take may not result in a highly polished UI/UX, it will be exceptionally functional ;)
Table of Contents
- Fonctional Specifications
- Focus Requester and Keyboard Management
- Custom Focus Manager Class
- Building Login Form
- Explannation on the focus
- Source Code
1- Fonctional Specifications
Our login form will consist of two input fields: the ‘Login’ field, which accepts any letter entered via the soft keyboard, and the ‘Password’ field, which requires numbers entered through a custom keyboard. Additionally, there will be a ‘Login’ button.
Focus Transition: The focus will transition from the ‘Login’ field to the ‘Password’ field and vice versa only when the respective input is fully filled by the user. if both the ‘Login’ and ‘Password’ fields are fully entered by the user, the focus will be cleared.
Soft Keyboard: The keyboard will only be visible for the ‘Login’ field. We utilize our custom keyboard for the ‘Password’ field.
Activation of ‘Login’ Button: The ‘Login’ button will become active only when both the ‘Login’ and ‘Password’ fields are fully entered by the user.
These functionalities will ensure that our login form is highly functional while providing an intuitive user experience
2- Focus Requester and KeyBoard managment
To manage focus in Compose, you must create a FocusRequester
and assign it to your view using the modifier. Afterward, simply use the requestFocus
function to focus the view. If the view is a TextField
, the keyboard will automatically appear.
@Composable
fun MyView(){
val focusRequest = remember { FocusRequester() }
TextField(
modifier = Modifier
.focusRequester(focusRequest)
...
)
LaunchedEffect(Unit) { focusRequest.requestFocus() }
}
Frequently, it’s necessary to manually control the visibility of the keyboard. For instance, clearing the focus doesn’t consistently hide the keyboard. To display or conceal the keyboard in Compose, you should obtain the SoftwareKeyboardController
instance via the local composition. You can then use the show
or hide
function as needed.
@Composable
fun MyView(){
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
...
onValueChange = {
if(valid) { keyboardController.hide() }
}
)
}
3- Custom Focus Manager Class
In our example, I won’t provide a simple demonstration of how to use the FocusRequester
and SoftwareKeyboardController
. Instead, I'll utilize a custom utility class that will handle these aspects for me. This class is designed to create any necessary FocusRequester
instances, keep track of the focused elements, and control the keyboard's visibility. I also have another set of classes at a higher level that handle more complex forms, but for now, let's keep it straightforward.
@OptIn(ExperimentalComposeUiApi::class)
class FocusDispatcher internal constructor(){
inner class FocusId internal constructor(internal val autoShowKeyboard: Boolean) {
val value: FocusRequester = FocusRequester()
fun onFocus() { onFocus(this) }
val hasFocus get () = hasFocus(this)
fun requestFocus() = requestFocus(this)
}
private val ids = mutableListOf<FocusId>()
private var keyboardController: SoftwareKeyboardController? = null
private lateinit var coroutine: CoroutineScope
private lateinit var focusManager: FocusManager
private lateinit var focusOwner: MutableState<FocusId?>
fun createId(autoShowKeyboard: Boolean = true) = FocusId(autoShowKeyboard).also { ids.add(it) }
fun destroyId(id: FocusId) = ids.remove(id)
@Composable
internal fun compose() {
coroutine = rememberCoroutineScope()
keyboardController = LocalSoftwareKeyboardController.current
focusManager = LocalFocusManager.current
focusOwner = remember { mutableStateOf(null) }
}
fun showKeyboard() {
coroutine.launch {
delay(150)
keyboardController?.show()
}
}
fun hideKeyboard() {
keyboardController?.hide()
}
fun requestClearFocus() {
focusOwner.value = null
focusManager.clearFocus(true)
hideKeyboard()
}
private fun onFocus(id: FocusId) {
focusOwner.value = id
if (id.autoShowKeyboard) {
showKeyboard()
} else {
hideKeyboard()
}
}
fun requestFocus(id: FocusId) {
if (focusOwner.value != id) {
kotlin.runCatching {
//TODO No idea why throw "IllegalStateException" sometimes but the focus is obtained anyway ...
//maybe add focus cemetery same I did in java?
id.value.requestFocus()
}
}
}
fun hasFocus(id: FocusId) = focusOwner.value == id
}
@Composable
fun rememberFocusDispatcher():FocusDispatcher {
return remember {
FocusDispatcher()
}.also { it.compose() }
}
fun Modifier.focusId(
id: FocusDispatcher.FocusId,
) = focusRequester(id.value)
.onFocusChanged {
if (it.isFocused) id.onFocus()
}
This class should be initialized within the “compose()” function. It serves as a focus manager, maintaining all the FocusRequester
instances. The class takes care of keyboard focus and permits anyone to request focus as needed. From an external perspective, you simply need to call rememberFocusDispatcher()
to instantiate it correctly.
You just need to add the focusId
to your view modifier to keep the focus dispatcher up to date and bind this view to this specific ID. Pretty straightforward, right? Let's take a look.
4- Building Login Form
To construct the form, since we’re not concerned with clean code in this context, I’ll take a “dirty” approach. My primary aim here is to discuss focus and keyboard management. When I say “dirty,” I mean it will meet only the minimum requirements for readability. To achieve this, I’ll use functions within functions, which may not be commonly used or are avoided like a diaper change at 3 a.m., but in this case, it’ll help keep the example straightforward.
I’ve removed all the layout-unrelated content to keep it concise. You can find the full code at the end for reference.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FocusExample() {
Column {
/* Here, we declare our focus IDs */
val focusDispatcher = rememberFocusDispatcher()
val login = remember { mutableStateOf("") }
val loginId = remember { focusDispatcher.createId() }
val password = remember { mutableStateOf("") }
val passwordId = remember { focusDispatcher.createId(autoShowKeyboard = false) }
/* Grant focus and display the keyboard upon initial appearance. */
LaunchedEffect(Unit) { loginId.requestFocus() }
/* LOGIN TEXT FIELD */
@Composable
fun LoginInput() {
TextField(modifier = Modifier
.focusId(loginId), /* bind the loginInput with the login id */
value = login.value,
onValueChange = {
onLoginChange(newValue = it) /* The code for this function is in the following section */
})
}
LoginInput()
/* PASSWORD TEXT FIELD */
@Composable
fun PasswordInput() {
Row {
CompositionLocalProvider(
LocalTextInputService provides null
) {
TextField(modifier = Modifier
.focusId(passwordId), /* bind the passwordInput with the passwordid */
value = password.value,
onValueChange = { password.value = it },
readOnly = true
)
}
Button(
onClick = {
passwordId.requestFocus()
password.value = ""
}) {
Text(text = "X")
}
}
}
PasswordInput()
/* PASSWORD CUSTOM KEYBOARD */
@Composable
fun PasswordKeyBoard() {
@Composable
fun Bouton(
value: String,
) {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = if(passwordId.hasFocus) Color.Blue else Color.LightGray
),
onClick = {
onPasswordChange(newValue = password.value + value) /* The code for this function is in the following section */
}) {
Text(text = value)
}
}
(0..1).forEach { up ->
Row {
(0..4).forEach { down ->
Bouton(value = "${(5 * up) + down}")
}
}
}
}
PasswordKeyBoard()
/* LOGIN BUTTON */
val context = LocalContext.current
Button(
enabled = isLoginValid(),
onClick = {
Toast.makeText(context, "Yeah!!!!!", Toast.LENGTH_LONG).show()
}) {
Text(text = if (isLoginValid()) "Rock'n Roll" else "-----", fontSize = 38.sp)
}
}
}
5- Explannation on the focus
From the onChange
call in the login TextField
or by clicking on the buttons of our custom digital keyboard, we call onLoginChange(newValue)
or onPasswordChange(newValue).
Throughout the entire form, we can use hasFocus
to update the view.
If you haven’t already noticed, hasFocus
is a MutableState
. So, each change in the focus owner will trigger an update of our Composable view, resulting in new colors, new text, new layout, or any other logic we've implemented in the design.
Here’s what’s inside our onXxxxxChange()
functions. We incorporate our logic to determine who can have the focus. As I mentioned earlier, all the Composable views will be updated accordingly based on this logic.
fun onLoginChange(newValue: String) {
if (newValue.length <= MAX_LOGIN_LENGTH) {
login.value = newValue
}
if (login.value.length >= MAX_LOGIN_LENGTH) {
if (password.value.length < MAX_PASSWORD_LENGTH) {
passwordId.requestFocus()
} else {
focusDispatcher.requestClearFocus()
}
}
}
fun onPasswordChange(newValue: String) {
passwordId.requestFocus()
if (password.value.length < MAX_PASSWORD_LENGTH) {
password.value = newValue
}
if (password.value.length >= MAX_PASSWORD_LENGTH) {
if (login.value.length < MAX_LOGIN_LENGTH) {
loginId.requestFocus()
} else {
focusDispatcher.requestClearFocus()
}
}
}
6- Source Code
Et voilà! You now have a complete working example here.
Feel free to explore and try it out. If you have any comments or thoughts to share, please leave them here. Thank you for reading. I would appreciate it if you choose to follow me; it will motivate me to write more on various topics, such as encryption, socket communication, and more. Have a wonderful day or night! ;)