Reputation: 21
Compose has FocusRequester() with the function requestFoucs() which seems to behave in unexpected and inconstant ways. The following is a great example of a focus issue which should be addressed.
Issue Overview:
We've implemented a HorizontalPager to showcase Cards. Users can swipe/navigate through these Card, and we've made efforts to ensure this feature is accessible to all users. However the focus mechanism behaves inconsistently during navigation.
Specific Behavior:*
Initial Focus: Initially, when navigating to the HorizontalPager, the focus lands correctly on the current active Card, as expected.
First Swipe/Next: Upon swiping to the next Card (to the right) the focus correctly moves to the new active Card.
Second Swipe: Here the focus unexpectedly clears instead of landing on the next Card.
Attempted Solutions:
We've tried implementing the LaunchedEffect(Unit) with a delay, followed by focusRequester.requestFocus() intending to manually request focus for the next Card in the sequence. Unfortunately, this approach hasn't triggered the expected behavior, and the focus still fails to land on the third Card as anticipated.
Impact:
This issue poses a significant barrier to accessibility, making it challenging for users relying on screen readers to fully engage with our content. Ensuring proper focusing while navigation through our HorizonalPager is very important to provide an inclusive user experience in our app.
Request for Assistance:
Given the technical nature of this issue, I am reaching out here for insights or suggestions on how to address this focus inconsistency. The requestFocus() should simply request focus just as it's named.
Attached is the mock code that demonstrates the issue:
// 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")
}
}
}
Upvotes: 2
Views: 1061
Reputation: 11
Things to be aware of:
The answer / Flow to make it work: Button pressed -> trigger scroll coroutine -> wait for scroll coroutine -> check that additional button presses / scroll coroutines are NOT running -> wait for the recompose to finish -> again check that additional button presses / scroll coroutines are NOT running -> requestFocus.
Code:
This is what the MainActivity would look like. We pass out a focusRequester for each card.
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
)
}
}
}
}
}
}
Our BasicPager would use HorizontalPagerWithTalkback which takes care of the talkback. We use the scrollToPage variable to indicate to the HorizontalPagerWithTalkback which page we want to scroll to.
@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")
}
}
}
This is the HorizontalPagerWithTalkback, the meat and potatoes of the answer. If there is a scrollToPage we start a scroll coroutine and capture that index, once that coroutine ends we capture that index and use it to check if we have any other scroll coroutines if not we start a side effect that waits for recomposition to end before checking if we are still the latest scrollToPage. If we are then we call focusRequester.
FocusRequesters are created on demand, because it will crash if we call a focusRequester that isn't attached to an object.
/** 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,
)
}
}
Upvotes: 1