Compose 有 FocusRequester() 和 requestFoucs() 函数,它的行为似乎出乎意料且不稳定。以下是应该解决的焦点问题的一个很好的例子。
问题概述:
我们实现了 HorizontalPager 来展示卡片。用户可以刷卡/浏览这些卡,我们已努力确保所有用户都可以使用此功能。然而,焦点机制在导航过程中表现不一致。
具体行为:*
初始焦点:最初,当导航到 HorizontalPager 时,焦点正确地落在当前活动卡片上,正如预期的那样。
第一次滑动/下一张:滑动到下一张卡(向右)时,焦点会正确移动到新的活动卡。
第二次滑动:此处焦点意外地清除,而不是落在下一张卡片上。
尝试的解决方案:
我们尝试延迟实现 LaunchedEffect(Unit),然后执行 focusRequester.requestFocus() ,旨在手动为序列中的下一张卡片请求焦点。不幸的是,这种方法并没有触发预期的行为,焦点仍然未能如预期那样落在第三张卡上。
影响:
此问题对可访问性造成了重大障碍,使得依赖屏幕阅读器的用户难以充分参与我们的内容。确保在 HorizonalPager 中导航时正确聚焦对于在我们的应用程序中提供包容性的用户体验非常重要。
请求协助:
鉴于此问题的技术性质,我在此寻求有关如何解决此焦点不一致问题的见解或建议。 requestFocus() 应该简单地请求焦点,就像它的名字一样。
附件是演示该问题的模拟代码:
// 1. Turn Talkback on from Accessibility suite (Google Play Store)
// 2. Launch app
// 3. Swipe/Next through Horizontal carousel
// 4. Notice how the accessibility focus clears upon the 2nd swipe
// Actual behavior: Accessibility focus is cleared after 2nd swipe.
// Expected behavior: Accessibility focus should always focus on the card after each swipe (left or right).
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val requester = remember { FocusRequester() }
TalkBackHorizontalPagerExperimentsTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier
.fillMaxSize()
.focusRequester(requester)
.focusable(),
color = MaterialTheme.colorScheme.background,
) {
val listOfCards = mutableListOf<Color>()
listOfCards.add(Color.Green)
listOfCards.add(Color.Black)
listOfCards.add(Color.Red)
listOfCards.add(Color.Yellow)
listOfCards.add(Color.Blue)
BasicPager(listOfCards = listOfCards) { index ->
SomeCard(color = listOfCards[index], requester)
}
}
}
}
}
}
@Composable
fun SomeCard(color: Color, requester: FocusRequester) {
Card(
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.border(width = 4.dp, color = Color.Black)
.clipToBounds()
.focusRequester(requester)
.focusable()
) {
Row(
Modifier
.fillMaxSize()
.background(color = color)
) {}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BasicPager(
listOfCards: MutableList<Color>,
itemContent: @Composable (index: Int) -> Unit,
) {
val itemsCount = listOfCards.size
val state = rememberPagerState { listOfCards.size }
val coroutineScope = rememberCoroutineScope()
HorizontalPager(state = state) { card ->
itemContent(card)
}
Box(
Modifier
.width(25.dp)
.height(25.dp)
) {
val temp = remember {
mutableStateOf(false)
}
Button(
modifier = Modifier.focusable(temp.value),
onClick = {
val nextPage = (state.currentPage + 1) % listOfCards.size
coroutineScope.launch {
state.animateScrollToPage(nextPage)
}
},
) {
Text("NEXT")
}
}
}
注意事项:
答案/使其发挥作用的流程: 按下按钮 -> 触发滚动协程 -> 等待滚动协程 -> 检查其他按钮按下/滚动协程是否未运行 -> 等待重构完成 -> 再次检查其他按钮按下/滚动协程是否未运行 ->请求焦点。
代码:
这就是 MainActivity 的样子。我们为每张卡传递一个 focusRequester。
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TalkBackHorizontalPagerExperimentsTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier
.fillMaxSize()
.focusable(),
color = MaterialTheme.colorScheme.background,
) {
val listOfCards = mutableListOf<Color>()
listOfCards.add(Color.Green)
listOfCards.add(Color.Black)
listOfCards.add(Color.Red)
listOfCards.add(Color.Yellow)
listOfCards.add(Color.Blue)
BasicPager(listOfCards = listOfCards) { focusRequester, index ->
SomeCard(
color = listOfCards[index], requester = focusRequester
)
}
}
}
}
}
}
我们的 BasicPager 将使用 HorizontalPagerWithTalkback 来处理对讲。我们使用scrollToPage变量向HorizontalPagerWithTalkback指示我们要滚动到哪个页面。
@Composable
fun BasicPager(
listOfCards: MutableList<Color>,
itemContent: @Composable (focusRequester: FocusRequester, index: Int) -> Unit,
) {
val itemsCount = listOfCards.size
var currentPage by remember {
mutableIntStateOf(0)
}
var scrollToPage: Int? by remember {
mutableStateOf(null)
}
HorizontalPagerWithTalkback(
itemCount = itemsCount,
scrollToPage = scrollToPage,
userScrollEnabled = true,
exposeCurrentPage = { index -> currentPage = index}
) { focusRequester, index ->
itemContent(focusRequester, index)
}
Box(
Modifier
.width(25.dp)
.height(25.dp)
) {
Button(
onClick = {
scrollToPage = (currentPage + 1) % itemsCount
},
) {
Text("NEXT")
}
}
}
这就是 HorizontalPagerWithTalkback,答案的核心。如果有一个scrollToPage,我们启动一个滚动协程并捕获该索引,一旦该协程结束,我们捕获该索引并用它来检查是否有任何其他滚动协程,如果没有,我们启动一个副作用,在检查之前等待重组结束如果我们还是最新的scrollToPage。如果是,那么我们调用 focusRequester。
FocusRequesters 是按需创建的,因为如果我们调用未附加到对象的 focusRequester,它将崩溃。
/** Handles requesting focus on the page after scrolling to the page.
* @param itemCount The number of items the pager has.
* @param scrollToPage The page number to scrolled to. If outside bounds loop it into the bounds i.e. -1 -> pagerState.pageCount - 1
* @param userScrollEnabled Whether the user can manually scroll via drag. Recommend turning this off when talkback is on.
* @param exposeCurrentPage Exposes the current page of the pagerState.
* @param pageContent What is displayed inside the horizontal pager. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalPagerWithTalkback(
itemCount: Int,
scrollToPage: Int? = null,
userScrollEnabled: Boolean,
exposeCurrentPage: (index: Int) -> Unit = {},
pageContent: @Composable (focusRequester: FocusRequester, index: Int) -> Unit,
) {
val pagerState = rememberPagerState { itemCount }
exposeCurrentPage(pagerState.currentPage)
val focusRequesters = remember {
mutableMapOf<Int, FocusRequester>()
}
//To create the focusRequesters on demand and have only 1 for each index
val createFocusRequester: @Composable (Int) -> FocusRequester = @Composable { index: Int ->
if (focusRequesters.containsKey(index) && focusRequesters[index] != null) {
focusRequesters[index]!!
} else {
focusRequesters[index] = remember {
FocusRequester()
}
focusRequesters[index]!!
}
}
//animateScrollToPage means that pagerState.currentPage may end up being between the start page and the end page as it's "animated"
val stateScroll: suspend (Int) -> Unit = { index: Int ->
pagerState.animateScrollToPage(index)
}
val coroutineScope = rememberCoroutineScope()
//Using indexAskedFor to keep track of the latest scrollToPage that's started scrolling
var indexAskedFor: Int by remember {
mutableIntStateOf(0)
}
//Using indexAskedFor to keep track of the latest scrollToPage that's finished scrolling
var lastIndexScrollTo: Int? by remember {
mutableStateOf(null)
}
//This checks indexAskedFor with lastIndexScrollTo and if everything is on the up and up calls the correct focusRequester
if (lastIndexScrollTo != null && indexAskedFor == lastIndexScrollTo) {
SideEffect {
if (lastIndexScrollTo != null && indexAskedFor == lastIndexScrollTo) {
focusRequesters[indexAskedFor]?.requestFocus()
}
lastIndexScrollTo = null
}
}
//This is what triggers the scrolling
if (scrollToPage != null) {
LaunchedEffect(scrollToPage) {
coroutineScope.launch {
indexAskedFor = scrollToPage
stateScroll(scrollToPage)
}.invokeOnCompletion {
lastIndexScrollTo = scrollToPage
}
}
}
HorizontalPager(
modifier = Modifier
.focusGroup()
.focusable(true),
state = pagerState,
userScrollEnabled = userScrollEnabled,
) { index ->
pageContent(
createFocusRequester(index), //This is where we create the focus requester for our object
index,
)
}
}