Как присвоить фокус на ту же позицию когда возвращаешся на экран?
Работаю в проекте над AndroidTV, особенность этой платформы в том, что юзер перемещает фокус по экрану с помощью пульта.
Вот такой экран:
Когда работаешь с фокусом то есть 2 основные задачи:
К когда перемещаешься слева направо и наоборот, то фокус не должен путать позиции. Например юзер делает правый клик
Left Panel 0 -> Right Panel 0
, в правой панели перемещаемся наRight Panel 2
нажимаем левый клик, фокус перемещается наLeft Panel 0
так как с нее юзер переместил фокус на правую панель. Эта функциональность была сделана с помощьюfocusRestorer
и уже работает.Когда юзер в правой панели нажимает (пример:) на
RigthPanel 1
, то открываетсяSecond Screen
, потом юзер нажимает назад и возвращается наFirst Screen
и тут ожидается, что в фокусе будет та кнопка с которой был открыт экран, то естьRight Panel 1
, но по какой то причене это работает в 30% случаев и вместо фокуса на ожидаемой кнопке в фокусе другая кнопка.
Видео -> https://drive.google.com/file/d/1NCal4kxx0op74-Yj5v00wOBcnCRSSlUb/view
Вот код его можно скопировать и запустить:
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")
}
}
}
}
}
По логам видно, что фокус присвается и вызывается на нужной кнопке, но на экране по какой то причение фокус на другой кнопке.
Есть предоположение, что есть баг в самой имплементации focusRequester
Что я пропускаю?
Ответы (1 шт):
В итоге я нашел такое решение -
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 */ }
}
}
Таким образом у меня получилось настроить фокус, чтоб он работал так как ожидается