我正在开发一个 Android TV 项目,该平台的独特功能是用户使用遥控器在屏幕上导航焦点。
这是屏幕:
专注工作时,有两个主要任务:
从左向右导航时(反之亦然),焦点不应混淆位置。例如,如果用户从
Left Panel 0 -> Right Panel 0
向右单击,然后导航到 Right Panel 2
,然后向左单击,则焦点应返回到 Left Panel 0
,因为用户将焦点从左侧面板移至右侧面板。此功能是使用 focusRestorer
实现的,并且已经开始工作。
当用户单击右侧面板中的某个项目(例如,
Right Panel 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
的实现可能存在bug。
我错过了什么?
最后,我找到了解决这个问题的方法,这是我的代码:
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 */ }
}
}
这样我就能够满足所有的焦点期望