androidkotlinandroid-jetpack-composeandroid-tv

How to assign focus to the same position when returning to the screen?


I'm working on a project for Android TV, where the unique feature of this platform is that the user navigates focus on the screen using a remote control.

Here is the screen:

введите сюда описание изображения

When working with focus, there are two main tasks:

  1. When navigating from left to right and vice versa, the focus should not confuse positions. For example, if the user clicks right from Left Panel 0 -> Right Panel 0, then navigates to Right Panel 2, and then clicks left, the focus should return to Left Panel 0 because the user moved the focus from the left panel to the right panel. This functionality was implemented using focusRestorer and is already working.

  2. When the user clicks on an item in the right panel (e.g., Right Panel 1), the Second Screen opens. When the user presses back and returns to the First Screen, it is expected that the focus will be on the button that opened the screen, i.e., Right Panel 1. However, for some reason, this only works 30% of the time, and instead of focusing on the expected button, a different button is focused.

ScreenRecord -> https://drive.google.com/file/d/1NCal4kxx0op74-Yj5v00wOBcnCRSSlUb/view

There is a ready-to-use code:

private const val FIRST_SCREEN_ROUTE = "first_screen"
private const val SECOND_SCREEN_ROUTE = "second_screen"
private const val DEFAULT_FOCUS_POSITION = -1

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Test_delete_itTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    shape = RectangleShape
                ) {
                    Greeting()
                }
            }
        }
    }
}

@Composable
fun Greeting() {
    val navigator: NavHostController = rememberNavController()

    NavHost(
        navController = navigator,
        startDestination = FIRST_SCREEN_ROUTE
    ) {
        composable(FIRST_SCREEN_ROUTE) {
            DisposableEffect(Unit) {
                Log.e("HERE", "1 CREATED first_screen_route")

                onDispose {
                    Log.e("HERE", "DISPOSED first_screen_route")
                }
            }

            FirstScreen(onClick = {
                Log.e("HERE", "NAVIGATION TO SECOND SCREEN")
                navigator.navigate(SECOND_SCREEN_ROUTE)
            })
        }

        composable(SECOND_SCREEN_ROUTE) {
            DisposableEffect(Unit) {
                Log.e("HERE", "CREATED second_screen_route")

                onDispose {
                    Log.e("HERE", "DISPOSED second_screen_route")
                }
            }

            SecondScreen()
        }
    }
}

@Composable
fun SecondScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Red.copy(alpha = 0.1f)),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "SECOND SCREEN")
    }
}

@Composable
fun FirstScreen(
    onClick: () -> Unit
) {
    var focusBtnIdx by rememberSaveable { mutableIntStateOf(DEFAULT_FOCUS_POSITION) }

    Row(modifier = Modifier
        .fillMaxSize()
    ) {
        LeftPanel()
        RightPanel(onClick = onClick, focusBtnIdx = focusBtnIdx, setFocusBtnIdx = { focusBtnIdx = it })
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RowScope.LeftPanel() {
    val firstItemFr = remember { FocusRequester() }
    val buttons by rememberSaveable { mutableStateOf(List(5) { "Button ${it + 1}" }) }

    LaunchedEffect(Unit) {
        this.coroutineContext.job.invokeOnCompletion {
            try { firstItemFr.requestFocus() }
            catch (e: Exception) {/* do nothing */ }
        }
    }

    TvLazyColumn(
        modifier = Modifier
            .focusRestorer { firstItemFr }
            .background(Color.Blue.copy(alpha = 0.1f))
            .fillMaxHeight()
            .weight(1f),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        itemsIndexed(
            items = buttons,
            key = { idx, _ -> idx }
        ) { idx, _ ->
            Button(
                modifier = Modifier
                    .let { modifier ->
                        if (idx == 0) {
                            modifier.focusRequester(firstItemFr)
                        } else {
                            modifier
                        }
                    },
                onClick = {}
            ) {
                Text(text = "Left Panel: $idx")
            }
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RowScope.RightPanel(
    onClick: () -> Unit,
    focusBtnIdx: Int,
    setFocusBtnIdx: (Int) -> Unit
) {
    val firstItemFr = remember { FocusRequester() }

    LaunchedEffect(Unit) {
        this.coroutineContext.job.invokeOnCompletion {
            try {
                Log.e("HERE", ">>> REQUEST FOCUS")
                if (focusBtnIdx != DEFAULT_FOCUS_POSITION) {
                    firstItemFr.requestFocus()
                    Log.e("HERE", "<<< REQUEST FOCUS")
                }
            }
            catch (e: Exception) {
                /* do nothing */
                Log.e("HERE", "FOCUS ERROR: $e")
            }
        }
    }

    Column(
        modifier = Modifier
            .background(Color.Green.copy(alpha = 0.1f))
            .fillMaxHeight()
            .weight(1f),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        val buttons: List<String> by rememberSaveable { mutableStateOf(List(4) { "Button ${it + 1}" }) }

        TvLazyVerticalGrid(
            modifier = Modifier
                .focusRestorer { firstItemFr }
                .padding(16.dp),
            columns = TvGridCells.Fixed(2),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            itemsIndexed(
                items = buttons,
                key = { idx, _ -> idx }
            ) { idx, _ ->
                Button(
                    modifier = Modifier
                        .padding(8.dp)
                        .let {
                            Log.e("HERE", "1 RightPanel: $idx")
                            if (idx == focusBtnIdx || (focusBtnIdx == DEFAULT_FOCUS_POSITION && idx == 0)) {
                                Log.e("HERE", "2 RightPanel: $idx")
                                it.focusRequester(firstItemFr)
                            } else {
                                it
                            }
                        },
                    onClick = {
                        setFocusBtnIdx(idx)
                        onClick()
                    }
                ) {
                    Text(text = "Right Panel: $idx")
                }
            }
        }
    }
}

From the logs, it is clear that the focus is assigned and called on the correct button, but for some reason, the focus is on a different button on the screen.

There is a suspicion that there might be a bug in the implementation of focusRequester.

What am I missing?


Solution

  • Finally, I found a way to tackle it, there is my code:

    import android.os.Bundle
    import android.util.Log
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.RowScope
    import androidx.compose.foundation.layout.fillMaxHeight
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.grid.GridCells
    import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
    import androidx.compose.foundation.lazy.grid.itemsIndexed
    import androidx.compose.foundation.lazy.itemsIndexed
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.DisposableEffect
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.saveable.rememberSaveable
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.ExperimentalComposeUiApi
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.focus.FocusRequester
    import androidx.compose.ui.focus.focusProperties
    import androidx.compose.ui.focus.focusRequester
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.graphics.RectangleShape
    import androidx.compose.ui.input.key.Key
    import androidx.compose.ui.input.key.KeyEventType
    import androidx.compose.ui.input.key.key
    import androidx.compose.ui.input.key.onPreviewKeyEvent
    import androidx.compose.ui.input.key.type
    import androidx.compose.ui.platform.LocalLifecycleOwner
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.Lifecycle
    import androidx.lifecycle.LifecycleEventObserver
    import androidx.navigation.NavHostController
    import androidx.navigation.compose.NavHost
    import androidx.navigation.compose.composable
    import androidx.navigation.compose.rememberNavController
    import androidx.tv.material3.Button
    import androidx.tv.material3.Surface
    import androidx.tv.material3.Text
    import com.krokosha.test_delete_it.ui.theme.Test_delete_itTheme
    
    private const val FIRST_SCREEN_ROUTE = "first_screen"
    private const val SECOND_SCREEN_ROUTE = "second_screen"
    private val focusRequesterMngFactory = FocusRequesterMng.Factory()
    private val focusRequesterWrapFactory = FocusRequesterWrap.Factory()
    private const val LEFT_PANEL_KEY = "LeftPanel"
    private const val RIGHT_PANEL_KEY = "RightPanel"
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                Test_delete_itTheme {
                    Surface(
                        modifier = Modifier.fillMaxSize(),
                        shape = RectangleShape
                    ) {
                        Greeting()
                    }
                }
            }
        }
    }
    
    @Composable
    fun Greeting() {
        val navigator: NavHostController = rememberNavController()
    
        NavHost(
            navController = navigator,
            startDestination = FIRST_SCREEN_ROUTE
        ) {
            composable(FIRST_SCREEN_ROUTE) {
                FirstScreen(onClick = {
                    Log.e("HERE", "NAVIGATION TO SECOND SCREEN")
                    navigator.navigate(SECOND_SCREEN_ROUTE)
                })
            }
    
            composable(SECOND_SCREEN_ROUTE) {
                SecondScreen()
            }
        }
    }
    
    @Composable
    fun SecondScreen() {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Red.copy(alpha = 0.1f)),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "SECOND SCREEN")
        }
    }
    
    @Composable
    fun FirstScreen(
        onClick: () -> Unit
    ) {
        val focusRequesterWrap: FocusRequesterWrap = focusRequesterWrapFactory.getBy(key = LEFT_PANEL_KEY)
        
        Row(modifier = Modifier
            .fillMaxSize()
        ) {
            LeftPanel(
                onClick = onClick,
                focusRequester = focusRequesterWrap.focusRequester
            )
    
            RightPanel(
                onClick = onClick,
                onBackBtnClicked = { focusRequesterWrap.requestFocus() }
            )
        }
    }
    
    @Composable
    fun RowScope.LeftPanel(
        onClick: () -> Unit,
        focusRequester: FocusRequester
    ) {
        val focusRequesterMng: FocusRequesterMng = focusRequesterMngFactory.getBy(
            key = LEFT_PANEL_KEY,
            parentFocusRequester = focusRequester,
            isInFocusOnInit = true
        )
    
        val buttons: List<String> by rememberSaveable { mutableStateOf(List(5) { "Button ${it + 1}" }) }
        val lifecycleOwner = LocalLifecycleOwner.current
    
        DisposableEffect(Unit) {
            val observer = LifecycleEventObserver { _, event ->
                if (event == Lifecycle.Event.ON_RESUME && focusRequesterMng.isNeedRestore) {
                    focusRequesterMng.onRestoreFocus()
                }
            }
    
            lifecycleOwner.lifecycle.addObserver(observer)
    
            onDispose {
                lifecycleOwner.lifecycle.removeObserver(observer)
            }
        }
    
        LazyColumn(
            modifier = focusRequesterMng.parentModifier
                .background(Color.Blue.copy(alpha = 0.1f))
                .fillMaxHeight()
                .weight(1f),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            itemsIndexed(
                items = buttons,
                key = { idx, _ -> idx }
            ) { idx, _ ->
                Button(
                    modifier = Modifier
                        .let { modifier ->
                            if (idx == 0) {
                                focusRequesterMng.childModifier
                            } else {
                                modifier
                            }
                        },
                    onClick = {
                        focusRequesterMngFactory.onNavigateOutFrom(focusRequesterMng = focusRequesterMng)
                        onClick()
                    }
                ) {
                    Text(text = "Left Panel: $idx")
                }
            }
        }
    }
    
    @Composable
    fun RowScope.RightPanel(
        onClick: () -> Unit,
        onBackBtnClicked: () -> Unit
    ) {
        val focusRequesterMng: FocusRequesterMng = focusRequesterMngFactory.getBy(key = RIGHT_PANEL_KEY)
        val buttons: List<String> by rememberSaveable { mutableStateOf(List(4) { "Button ${it + 1}" }) }
    
        val lifecycleOwner = LocalLifecycleOwner.current
    
        DisposableEffect(Unit) {
            val observer = LifecycleEventObserver { _, event ->
                if (event == Lifecycle.Event.ON_RESUME && focusRequesterMng.isNeedRestore) {
                    focusRequesterMng.onRestoreFocus()
                }
            }
    
            lifecycleOwner.lifecycle.addObserver(observer)
    
            onDispose {
                lifecycleOwner.lifecycle.removeObserver(observer)
            }
        }
    
        Column(
            modifier = focusRequesterMng
                .parentModifier
                .onPreviewKeyEvent {
                    when {
                        KeyEventType.KeyUp == it.type && Key.Back == it.key -> {
                            onBackBtnClicked()
                            true
                        }
    
                        else -> false
                    }
                }
                .background(Color.Green.copy(alpha = 0.1f))
                .fillMaxHeight()
                .weight(1f),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            LazyVerticalGrid(
                modifier = Modifier.padding(16.dp),
                columns = GridCells.Fixed(2),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                itemsIndexed(
                    items = buttons,
                    key = { idx, _ -> idx }
                ) { idx, _ ->
                    Button(
                        modifier = Modifier
                            .padding(8.dp)
                            .let { modifier ->
                                if (idx == 0) focusRequesterMng.childModifier
                                else modifier
                            }
                        ,
                        onClick = {
                            focusRequesterMngFactory.onNavigateOutFrom(focusRequesterMng = focusRequesterMng)
                            onClick()
                        }
                    ) {
                        Text(text = "Right Panel: $idx")
                    }
                }
            }
        }
    }
    
    class FocusRequesterMng private constructor(
        val id: String,
        val parentModifier: Modifier,
        val parentFocusRequester: FocusRequester,
        val childModifier: Modifier,
        val childFocusRequester: FocusRequester,
        var isNeedRestore: Boolean
    ) {
        class Factory {
            private val focusRequesterMngMap: MutableMap<String, FocusRequesterMng> = mutableMapOf()
    
            fun getBy(
                key: String,
                parentFocusRequester: FocusRequester = FocusRequester(),
                isInFocusOnInit: Boolean = false
            ): FocusRequesterMng {
                val focusRequesterMng: FocusRequesterMng = focusRequesterMngMap
                    .getOrPut(key) {
                        create(
                            id = key,
                            parentFocusRequester = parentFocusRequester,
                            isInFocusOnInit = isInFocusOnInit
                        )
                    }
    
                if (isInFocusOnInit && focusRequesterMng.isNeedRestore) {
                    focusRequesterMngMap.forEach { (key, mng) -> mng.isNeedRestore = key == focusRequesterMng.id }
                }
    
                return focusRequesterMng
            }
    
            // Whenever we have a navigation event, need to call this before actually navigating.
            fun onNavigateOutFrom(focusRequesterMng: FocusRequesterMng) {
                focusRequesterMngMap.forEach { (key, mng) ->
                    if (key == focusRequesterMng.id) {
                        mng.onNavigateOut()
                    } else {
                        mng.resetNeedsRestore()
                    }
                }
            }
        }
    
        @OptIn(ExperimentalComposeUiApi::class)
        fun onNavigateOut() {
            isNeedRestore = true
            parentFocusRequester.saveFocusedChild()
        }
    
        fun resetNeedsRestore() {
            isNeedRestore = false
        }
    
        fun onRestoreFocus() {
            childFocusRequester.requestFocus()
            resetNeedsRestore()
        }
    
        companion object {
            /**
             * Returns a set of modifiers [FocusRequesterMng] which can be used for restoring focus and
             * specifying the initially focused item.
             */
            @OptIn(ExperimentalComposeUiApi::class)
            fun create(
                id: String = "",
                parentFocusRequester: FocusRequester = FocusRequester(),
                isInFocusOnInit: Boolean = false
            ): FocusRequesterMng {
                val childFocus = FocusRequester()
    
                val parentModifier = Modifier
                    .focusRequester(parentFocusRequester)
                    .focusProperties {
                        exit = {
                            parentFocusRequester.saveFocusedChild()
                            FocusRequester.Default
                        }
                        enter = {
                            if (parentFocusRequester.restoreFocusedChild()) {
                                FocusRequester.Cancel
                            } else {
                                childFocus
                            }
                        }
                    }
    
                val childModifier = Modifier.focusRequester(childFocus)
    
                return FocusRequesterMng(
                    id = id,
                    parentModifier = parentModifier,
                    parentFocusRequester = parentFocusRequester,
                    childModifier = childModifier,
                    childFocusRequester = childFocus,
                    isNeedRestore = isInFocusOnInit
                )
            }
        }
    }
    
    class FocusRequesterWrap private constructor() {
        class Factory {
            private val focusRequesterWrapMap: MutableMap<String, FocusRequesterWrap> = mutableMapOf()
    
            fun getBy(key: String): FocusRequesterWrap {
                return focusRequesterWrapMap.getOrPut(key) { FocusRequesterWrap() }
            }
        }
    
        val focusRequester: FocusRequester by lazy { FocusRequester() }
    
        fun requestFocus() {
            try { focusRequester.requestFocus() }
            catch (e: Exception) { /* do nothing */ }
        }
    }
    

    This way I was able to handle all the focus expectations