Android Jetpack Compose TV 焦点恢复

问题描述 投票:0回答:3

我在 TvLazyColumn 中有 TvLazyRows。当我导航到所有列表的末尾(位置 [20,20])导航到下一个屏幕并返回时,焦点将恢复到第一个可见位置 [15,1],而不是我之前所在的位置 [20,20] ]。 如何将焦点恢复到某个特定位置?

class MainActivity : ComponentActivity() {

    private val rowItems = (0..20).toList()
    private val rows = (0..20).toList()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            MyAppNavHost(navController = navController)
        }
    }

    @Composable
    fun List(navController: NavController) {
        val fr = remember {
            FocusRequester()
        }
        TvLazyColumn( modifier = Modifier
            .focusRequester(fr)
            .fillMaxSize()
            ,
            verticalArrangement = Arrangement.spacedBy(16.dp),
            pivotOffsets = PivotOffsets(parentFraction = 0.05f),
        ) {
            items(rows.size) { rowPos ->
                Column() {
                    Text(text = "Row $rowPos")
                    TvLazyRow(
                        modifier = Modifier
                            .height(70.dp),
                        horizontalArrangement = Arrangement.spacedBy(16.dp),
                        pivotOffsets = PivotOffsets(parentFraction = 0.0f),
                    ) {
                        items(rowItems.size) { itemPos ->
                            var color by remember {
                                mutableStateOf(Color.Green)
                            }
                            Box(
                                Modifier
                                    .width(100.dp)
                                    .height(50.dp)
                                    .onFocusChanged {
                                        color = if (it.hasFocus) {
                                            Color.Red
                                        } else {
                                            Color.Green
                                        }
                                    }
                                    .background(color)
                                    .clickable {
                                        navController.navigate("details")
                                    }


                            ) {
                                Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
                            }
                        }
                    }
                }
            }
        }
        LaunchedEffect(true) {
            fr.requestFocus()
        }
    }

    @Composable
    fun MyAppNavHost(
        navController: NavHostController = rememberNavController(),
        startDestination: String = "list"
    ) {
        NavHost(
            navController = navController,
            startDestination = startDestination
        ) {
            composable("details") {
                Details()
            }
            composable("list") { List(navController) }
        }
    }

    @Composable
    fun Details() {
        Box(
            Modifier
                .background(Color.Blue)
                .fillMaxSize()) {
            Text("Second Screen", Modifier.align(Alignment.Center), fontSize = 48.sp)
        }
    }

}

版本

dependencies {

    implementation 'androidx.core:core-ktx:1.10.1'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
    implementation 'androidx.activity:activity-compose:1.7.1'
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    debugImplementation 'androidx.compose.ui:ui-tooling'
    debugImplementation 'androidx.compose.ui:ui-test-manifest'

    // Compose for TV dependencies
    def tvCompose = '1.0.0-alpha06'
    implementation "androidx.tv:tv-foundation:$tvCompose"
    implementation "androidx.tv:tv-material:$tvCompose"

    def nav_version = "2.5.3"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

我尝试将 FocusRequestor 传递给列表中的每个可聚焦元素。在这种情况下,我能够恢复注意力。但是对于列表中的大量元素,它开始因 OutOfMemmoryError 而崩溃。所以我需要其他解决方案。

android kotlin android-jetpack-compose android-tv android-jetpack-compose-tv
3个回答
4
投票

在 Jetpack Compose 中,导航在设计上是无状态的,这意味着默认情况下不保留焦点状态。为了实现这一点,我们必须自己维护状态(项目的位置)。

下面是一个建议的解决方案,您可以将其集成到您的代码中。请注意,此解决方案的工作假设是列表中的项目不会动态更改。如果他们这样做,你可能需要稍微调整一下逻辑:

  1. 您需要将最后一个焦点项目保持在某种状态。
private var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
  1. 当某个项目获得焦点时,您需要更新
    lastFocusedItem
.onFocusChanged { focusState ->
    if (focusState.hasFocus) {
        lastFocusedItem = Pair(rowPos, itemPos)
    }
    ...
}
  1. 当您导航回列表屏幕时,您需要为最后一个焦点项目请求焦点。
LaunchedEffect(true) {
    // Request focus to the last focused item
    focusRequesters[lastFocusedItem]?.requestFocus()
}

为了实现最后一点,我们需要一张

FocusRequester
的地图。我们应该使用一个映射,其中键作为项目位置(rowPos、itemPos),值作为
FocusRequester
s。

这是代码的更新部分,用于维护和恢复上次导航项目的焦点。

这是一个两步过程:

  1. 创建一个可变状态映射,将 (rowPos, itemPos) 对作为键,并将其相应的
    FocusRequester
    作为值。使用 RememberSaveable 在屏幕导航期间保留值。
  2. 记住每个项目的
    FocusRequester
    并将其添加到
    focusRequesters
    地图中。

您更新的

List
可组合项可能如下所示:

@Composable
fun List(navController: NavController) {
    val focusRequesters = remember { mutableMapOf<Pair<Int, Int>, FocusRequester>() }
    var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
    TvLazyColumn(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(16.dp),
        pivotOffsets = PivotOffsets(parentFraction = 0.05f),
    ) {
        items(rows.size) { rowPos ->
            Column() {
                Text(text = "Row $rowPos")
                TvLazyRow(
                    modifier = Modifier
                        .height(70.dp),
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                    pivotOffsets = PivotOffsets(parentFraction = 0.0f),
                ) {
                    items(rowItems.size) { itemPos ->
                        var color by remember { mutableStateOf(Color.Green) }
                        val fr = remember { FocusRequester() }
                        focusRequesters[Pair(rowPos, itemPos)] = fr
                        Box(
                            Modifier
                                .width(100.dp)
                                .height(50.dp)
                                .focusRequester(fr)
                                .onFocusChanged {
                                    color = if (it.hasFocus) {
                                        lastFocusedItem = Pair(rowPos, itemPos)
                                        Color.Red
                                    } else {
                                        Color.Green
                                    }
                                }
                                .background(color)
                                .clickable {
                                    navController.navigate("details")
                                }
                        ) {
                            Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
                        }
                    }
                }
            }
        }
    }
    LaunchedEffect(true) {
        focusRequesters[lastFocusedItem]?.requestFocus()
    }
}

PS。拥有可组合的方法是一个坏主意。可组合项应该是没有副作用的纯函数。


3
投票

@corvinav 发布了一个很好的方法。但是,如果在请求焦点之前未将

focusRequester
附加到元素,则该方法可能会导致应用程序崩溃。在这里查看更多详细信息:gBug - 276738340

总结@corvinav方法中的问题:

  • 如果用户单击了需要滚动的行或列中的项目,则此方法将不起作用,因为该项目不会出现在请求焦点的视图中
  • 即使该项目的索引在视图中,
    LaunchedEffect
    也不能保证该项目已准备好请求焦点。
  • 所有焦点请求者都存储在地图中。如果行/列被修改,那么基于索引的获取焦点请求者的方法可能不起作用。

更好的方法是在

navController.popBackStack()
页面上使用
details
调用,这将恢复上一页,并为您保留许多内容,例如跨
TvLazyRow
TvLazyColumn
的滚动状态。它当前不做的一件事是将焦点恢复到最后一个聚焦的项目。您可以通过使用焦点请求器来做到这一点,但对 @corvinav 的方法稍作修改。我们可以利用
onGloballyPositioned
修饰符来保证该项目位于视图中并准备好接受焦点。

恢复之前状态的两个关键点:

  • 使用
    navController.pop*
    方法之一恢复以前的页面。查看 jetpack/compose/navigation 了解更多详情
  • 记得将上面的调用也添加到
    BackHandler

演示:

找到上述演示的相关代码片段:

@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun App() {
    val navController = rememberNavController()
    val lastFocusedItemId = remember { mutableStateOf<String?>(null) }

    // To avoid requesting focus on the item more than once
    val itemAlreadyFocused = remember { mutableStateOf(false) }

    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            LaunchedEffect(Unit) {
                // reset the value when home is launched
                itemAlreadyFocused.value = false
            }
            HomePage(
                navController = navController,
                lastFocusedItemId = lastFocusedItemId,
                itemAlreadyFocused = itemAlreadyFocused,
            )
        }
        composable("movie") {
            BackHandler {
                navController.popBackStack()
            }
            Button(onClick = { navController.popBackStack() }) {
                Text("Home")
            }
        }
    }
}

@Composable
fun HomePage(
    navController: NavController,
    lastFocusedItemId: MutableState<String?>,
    itemAlreadyFocused: MutableState<Boolean>,
) {
    TvLazyColumn(Modifier.fillMaxSize()) {
        // `rows` could be coming from your view model
        itemsIndexed(rows) { index, rowData ->
            MyRow(
                rowData = rowData,
                navController = navController,
                lastFocusedItemId = lastFocusedItemId,
                itemAlreadyFocused = itemAlreadyFocused,
            )
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MyRow(
    rowData: List<MyItem>,
    navController: NavController,
    lastFocusedItemId: MutableState<String?>,
    itemAlreadyFocused: MutableState<Boolean>,
) {
    TvLazyRow(
        horizontalArrangement = Arrangement.spacedBy(20.dp),
        modifier = Modifier.focusRestorer()
    ) {
        items(myItems) { item ->
            key(item.id) {
                val focusRequester = remember { FocusRequester() }
                Card(
                    modifier = Modifier
                        .focusRequester(focusRequester)
                        .onGloballyPositioned {
                            if (
                                lastFocusedItemId.value == item.id &&
                                !itemAlreadyFocused.value
                            ) {
                                focusRequester.requestFocus()
                            }
                        }
                        .onFocusChanged {
                            if (it.isFocused) {
                                lastFocusedItemId.value = item.id
                                itemAlreadyFocused.value = true
                            }
                        },
                    onClick = { navController.navigate("movie") }
                )
            }
        }
    }
}

0
投票

我在这里有一个不同的方法,它不会阻止列表之外的其他元素获得焦点。

var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }

 GridItem(channel,
 onClick = {
                //on click logic

                //Store the focused element id when clicked
                lastFocusedChannel = channel.hashCode()
            }, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {

                 // onFocusChanged Listener to Reset stored value after accuiring focus
                if (it.hasFocus) lastFocusedChannel = null
            }
        }
//...
// In each Item
// Apply the modifier

  val focusRequester = remember { FocusRequester() }

    Card(
        onClick = { onClick.invoke(channel) },
        modifier = Modifier
            .size(channelItemSize)
            .onFocusChanged(onFocusChanged)
            .focusRequester(focusRequester)
            .onGloballyPositioned {
               if(hadFocusBeforeNavigation) focusRequester.requestFocus()
                }
            })

完整代码片段:

package dev.khaled.leanstream.channels

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BrokenImage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.Border
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import coil.compose.AsyncImage
import dev.khaled.leanstream.conditional

val channelItemSize = 128.dp
val isTouchScreen = false //TODO


@Composable
fun ChannelsGrid(items: List<Channel>, onClick: (channel: Channel) -> Unit) {
    var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }

    TvLazyVerticalGrid(
        columns = TvGridCells.Adaptive(channelItemSize),
        verticalArrangement = Arrangement.spacedBy(16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        contentPadding = PaddingValues(16.dp),
    ) {
        items(items) { channel ->
            GridItem(channel, onClick = {
                onClick(it)
                lastFocusedChannel = channel.hashCode()
            }, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {
                if (it.hasFocus) lastFocusedChannel = null
            }
        }
    }
}


@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun GridItem(
    channel: Channel,
    onClick: (channel: Channel) -> Unit,
    hadFocusBeforeNavigation: Boolean,
    onFocusChanged: (FocusState) -> Unit,
) {
    val focusRequester = remember { FocusRequester() }

    Card(
        onClick = { onClick.invoke(channel) },
        modifier = Modifier
            .size(channelItemSize)
            .onFocusChanged(onFocusChanged)
            .focusRequester(focusRequester)
            .conditional(hadFocusBeforeNavigation) {
                onGloballyPositioned {
                    focusRequester.requestFocus()
                }
            }
            .conditional(isTouchScreen) {
                clickable { onClick.invoke(channel) }
                clip(RoundedCornerShape(16))
            },
        border = CardDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.Black))),
    ) {
        AsyncImage(
            model = channel.icon,
            modifier = Modifier.fillMaxSize(),
            contentDescription = null,
            error = rememberVectorPainter(Icons.Rounded.BrokenImage),
        )
    }
}

结果:

© www.soinside.com 2019 - 2024. All rights reserved.