avatarTezov

Summary

The article provides a comprehensive guide to managing focus and keyboard interactions in Android Compose, specifically within a login form scenario.

Abstract

This article delves into the intricacies of handling focus and soft keyboard behavior in Android Compose, using a practical example of a login form. It outlines functional specifications for the form, which includes two input fields—'Login' and 'Password'—and a 'Login' button. The focus transitions between fields and keyboard visibility are carefully controlled based on user input. A custom FocusDispatcher class is introduced to manage focus requests and keyboard visibility, simplifying the process for developers. The article also presents a step-by-step approach to building the login form, emphasizing focus management over UI/UX polish. It concludes with a discussion on how focus changes trigger updates in the Composable views and provides the complete source code for developers to explore and follow along.

Opinions

  • The author emphasizes functionality over a highly polished UI/UX, suggesting that the provided approach prioritizes intuitive user experience and efficient management of focus and keyboard interactions.
  • The use of a custom FocusDispatcher class is advocated for its ability to encapsulate focus and keyboard management logic, making it easier for developers to handle these aspects in their applications.
  • The article acknowledges that the code examples provided may not adhere to the cleanest coding practices, as the primary focus is on demonstrating focus and keyboard management rather than code readability or maintainability.
  • The author suggests that the focus dispatcher approach is straightforward and effective, as it allows for simple integration into Composable views by using the focusId modifier.
  • By providing the full source code and encouraging feedback, the author shows a commitment to community engagement and a willingness to share knowledge with fellow developers.

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.

Pixaba : Gerd Altmann

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

  1. Fonctional Specifications
  2. Focus Requester and Keyboard Management
  3. Custom Focus Manager Class
  4. Building Login Form
  5. Explannation on the focus
  6. 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.

medium.adr.textfield_focus

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! ;)

Recommended from ReadMedium