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:
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.
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?
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