Search code examples
androidandroid-jetpackandroid-jetpack-composefocusmanagercomposable

How to manage Focus state in Jetpack's Compose


I have a custom composable View (Surface + Text essentially) and I want to change the color of the surface depending on the focus state. The FocusManager#FocusNode is marked internal and I am unaware of any way to achieve this. Is this simply just not available yet? Any one else have to tackle this?


Solution

  • As of dev11, FocusManagerAmbient has been deprecated in favor of FocusModifier. For more examples check out the KeyInputDemo, ComposeInputFieldFocusTransition or FocusableDemo.

    @Composable
    private fun FocusableText(text: MutableState<String>) {
        val focusModifier = FocusModifier()
        Text(
            modifier = focusModifier
                .tapGestureFilter { focusModifier.requestFocus() }
                .keyInputFilter { it.value?.let { text.value += it; true } ?: false },
            text = text.value,
            color = when (focusModifier.focusState) {
                Focused -> Color.Green
                NotFocused -> Color.Black
                NotFocusable -> Color.Gray
            }
        )
    }
    

    Compose has since updated and made the FocusManager members public; although, I'm not exactly sure how final the api is as of dev10. But as of now you can create a FocusNode and listen for changes using FocusManager.registerObserver.

    val focusNode = remember {
        FocusNode().apply {
            focusManager.registerObserver(node = this) { fromNode, toNode ->
                if (toNode == this) {
                    // has focus
                } else {
                    // lost focus
                }
            }
        }
    }
    

    If you'd like to gain focus, you can now call FocusManager.requestFocus:

    val focusManager = FocusManagerAmbient.current
    focusManager.requestFocus(focusNode)
    

    You can also set a focusIdentifier on your FocusNode:

    val focusNode = remember {
        FocusNode().apply {
            ...
            focusManager.registerFocusNode("your-focus-identifier", node = this)
        }
    }
    

    To gain focus for a specific identifier, you just call FocusManager.requestFocusById

    Using that you can easily create a Composable that can provide and request focus for you, for instance:

    @Composable
    fun useFocus(focusIdentifier: String? = null): Pair<Boolean, () -> Unit> {
        val focusManager = FocusManagerAmbient.current
        val (hasFocus, setHasFocus) = state { false }
        val focusNode = remember {
            FocusNode().apply {
                focusManager.registerObserver(node = this) { fromNode, toNode ->
                    setHasFocus(toNode == this)
                }
                focusIdentifier?.let { identifier ->
                    focusManager.registerFocusNode(identifier, node = this)
                }
            }
        }
        onDispose {
            focusIdentifier?.let { identifier ->
                focusManager.unregisterFocusNode(identifier)
            }
        }
        return hasFocus to {
            focusManager.requestFocus(focusNode)
        }
    }
    
    val (hasFocus, requestFocus) = useFocus("your-focus-identifier")
    

    You could also compose children along with it:

    @Composable
    fun FocusableTextButton(
        text: String,
        focusedColor: Color = Color.Unset,
        unFocusedColor: Color = Color.Unset,
        textColor: Color = Color.White,
        focusIdentifier: String? = null
    ) {
        val (hasFocus, requestFocus) = useFocus(focusIdentifier)
        Surface(color = if (hasFocus) focusedColor else unFocusedColor) {
            TextButton(onClick = requestFocus) {
                Text(text = text, color = textColor)
            }
        }
    }
    

    Alternatively, there's also FocusModifier, which as of now is:

    /**
     * A [Modifier.Element] that wraps makes the modifiers on the right into a Focusable. Use a
     * different instance of [FocusModifier] for each focusable component.
     *
     * TODO(b/152528891): Write tests for [FocusModifier] after we finalize on the api (API
     * review tracked by b/152529882).
     */
    

    But I don't think you can apply an identifier with it right now.

    val focusModifier = FocusModifier()
    val hasFocus = focusModifier.focusDetailedState == FocusDetailedState.Active
    Surface(
        modifier = focusModifier,
        color = if (hasFocus) focusedColor else unFocusedColor
    ) {
        TextButton(onClick = { focusModifier.requestFocus() }) {
            Text(text = text, color = textColor)
        }
    }
    

    All that being said, I'm not 100% sure this is the intended way to handle focus right now. I referenced CoreTextField a lot to see how it was being handled there.

    Example:

    @Composable
    fun FocusTest() {
        val focusManager = FocusManagerAmbient.current
        val selectRandomIdentifier: () -> Unit = {
            focusManager.requestFocusById(arrayOf("red,", "blue", "green", "yellow").random())
        }
        Column(verticalArrangement = Arrangement.SpaceBetween) {
            FocusableTextButton(
                text = "When I gain focus, I turn red",
                focusedColor = Color.Red,
                focusIdentifier = "red"
            )
            FocusableTextButton(
                text = "When I gain focus, I turn blue",
                focusedColor = Color.Blue,
                focusIdentifier = "blue"
            )
            FocusableTextButton(
                text = "When I gain focus, I turn green",
                focusedColor = Color.Green,
                focusIdentifier = "green"
            )
            FocusableTextButton(
                text = "When I gain focus, I turn yellow",
                focusedColor = Color.Yellow,
                focusIdentifier = "yellow"
            )
            Button(onClick = selectRandomIdentifier) {
                Text(text = "Click me to randomly select a node to focus")
            }
        }
    }
    

    gif