feat: fast-multi-click scrolling in tv-compose-material-carousel
Test: Added Instrumentation tests
Bug: b/263230588
Change-Id: I59d62afa2eedef07d576cd7db1336c72ab73dd79
diff --git a/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/FeaturedCarousel.kt b/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/FeaturedCarousel.kt
index 821281f..15d409d 100644
--- a/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/FeaturedCarousel.kt
+++ b/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/FeaturedCarousel.kt
@@ -66,7 +66,21 @@
)
}
}
- FeaturedCarousel()
+
+ FeaturedCarousel(Modifier.weight(1f))
+
+ Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ repeat(3) {
+ Box(
+ modifier = Modifier
+ .background(Color.Magenta.copy(alpha = 0.3f))
+ .width(50.dp)
+ .height(50.dp)
+ .drawBorderOnFocus()
+ .focusable()
+ )
+ }
+ }
}
}
items(2) { SampleLazyRow() }
@@ -83,18 +97,23 @@
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
-internal fun FeaturedCarousel() {
+internal fun FeaturedCarousel(modifier: Modifier = Modifier) {
val backgrounds = listOf(
Color.Red.copy(alpha = 0.3f),
Color.Yellow.copy(alpha = 0.3f),
- Color.Green.copy(alpha = 0.3f)
+ Color.Green.copy(alpha = 0.3f),
+ Color.Blue.copy(alpha = 0.3f),
+ Color.LightGray.copy(alpha = 0.3f),
+ Color.Magenta.copy(alpha = 0.3f),
+ Color.DarkGray.copy(alpha = 0.3f),
+ Color.LightGray.copy(alpha = 0.3f),
)
val carouselState = remember { CarouselState() }
Carousel(
slideCount = backgrounds.size,
carouselState = carouselState,
- modifier = Modifier
+ modifier = modifier
.height(300.dp)
.fillMaxWidth(),
carouselIndicator = {
@@ -113,7 +132,6 @@
Box(
modifier = Modifier
.background(backgrounds[itemIndex])
- .border(2.dp, Color.White.copy(alpha = 0.5f))
.fillMaxSize()
)
}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
index 2b0855f..6e600c6 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package androidx.tv.material
+package androidx.tv.material3
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -61,13 +61,6 @@
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
-import androidx.tv.material3.Carousel
-import androidx.tv.material3.CarouselDefaults
-import androidx.tv.material3.CarouselItem
-import androidx.tv.material3.CarouselItemDefaults
-import androidx.tv.material3.CarouselState
-import androidx.tv.material3.ExperimentalTvMaterial3Api
-import androidx.tv.material3.ScrollPauseHandle
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
@@ -572,6 +565,58 @@
}
@Test
+ fun carousel_manualScrolling_fastMultipleKeyPresses() {
+ val carouselState = CarouselState()
+ val tabs = listOf("Tab 1", "Tab 2", "Tab 3")
+
+ rule.setContent {
+ var selectedTabIndex by remember { mutableStateOf(0) }
+
+ Column {
+ TabRow(selectedTabIndex = selectedTabIndex) {
+ tabs.forEachIndexed { index, tab ->
+ Tab(
+ selected = index == selectedTabIndex,
+ onFocus = { selectedTabIndex = index },
+ ) {
+ Text(text = tab)
+ }
+ }
+ }
+
+ SampleCarousel(carouselState = carouselState, slideCount = 20) {
+ SampleCarouselSlide(modifier = Modifier.testTag("slide-$it"), index = it)
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ rule.onNodeWithTag("pager").performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ val slideProgression = listOf(6, 3, -4, 3, -6, 5, 3)
+
+ slideProgression.forEach {
+ if (it < 0) {
+ performKeyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT, it * -1)
+ } else {
+ performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, it)
+ }
+ }
+
+ rule.mainClock.advanceTimeBy(animationTime + overlayRenderWaitTime)
+
+ val finalSlide = slideProgression.sum()
+ rule.onNodeWithText("Play $finalSlide").assertIsFocused()
+
+ performKeyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 3)
+
+ rule.mainClock.advanceTimeBy((animationTime + overlayRenderWaitTime) * 3)
+
+ rule.onNodeWithText("Play ${finalSlide + 3}").assertIsFocused()
+ }
+
+ @Test
fun carousel_manualScrolling_ltr() {
rule.setContent {
SampleCarousel { index ->
@@ -679,7 +724,7 @@
modifier = Modifier
.padding(5.dp)
.fillMaxWidth()
- .height(50.dp)
+ .height(200.dp)
.testTag("pager"),
carouselState = carouselState,
slideCount = slideCount,
@@ -702,10 +747,13 @@
@Composable
private fun SampleCarouselSlide(
index: Int,
+ modifier: Modifier = Modifier,
overlayRenderWaitTime: Long = CarouselItemDefaults.OverlayEnterTransitionStartDelayMillis,
content: (@Composable () -> Unit) = { SampleButton("Play $index") },
) {
+
CarouselItem(
+ modifier = modifier,
overlayEnterTransitionStartDelayMillis = overlayRenderWaitTime,
background = {
Box(
@@ -748,10 +796,11 @@
itemRect.bottom <= rootRect.bottom
}
-private fun performKeyPress(keyCode: Int, count: Int = 1) {
+private fun performKeyPress(keyCode: Int, count: Int = 1, afterEachPress: () -> Unit = { }) {
repeat(count) {
InstrumentationRegistry
.getInstrumentation()
.sendKeyDownUpSync(keyCode)
+ afterEachPress()
}
}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
index 60fa29f..ffcef53 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
@@ -17,6 +17,8 @@
package androidx.tv.material3
import android.view.KeyEvent.KEYCODE_BACK
+import android.view.KeyEvent.KEYCODE_DPAD_LEFT
+import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
@@ -41,20 +43,19 @@
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
-import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown
+import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.onKeyEvent
@@ -86,7 +87,6 @@
* @param carouselIndicator indicator showing the position of the current slide among all slides.
* @param content defines the slides for a given index.
*/
-
@Suppress("IllegalExperimentalApiUsage")
@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class)
@ExperimentalTvMaterial3Api
@@ -127,27 +127,31 @@
Box(modifier = modifier
.bringIntoViewIfChildrenAreFocused()
.focusRequester(carouselOuterBoxFocusRequester)
- .onKeyEvent {
- if (it.key.nativeKeyCode == KEYCODE_BACK && it.type == KeyDown) {
- focusManager.moveFocus(FocusDirection.Exit)
- }
- false
- }
.onFocusChanged {
focusState = it
+
+ // When the carousel gains focus for the first time
if (it.isFocused && isAutoScrollActive) {
focusManager.moveFocus(FocusDirection.Enter)
}
}
- .manualScrolling(carouselState, slideCount, isLtr)
- .focusable()) {
+ .handleKeyEvents(
+ carouselState = carouselState,
+ outerBoxFocusRequester = carouselOuterBoxFocusRequester,
+ focusManager = focusManager,
+ slideCount = slideCount,
+ isLtr = isLtr,
+ )
+ .focusable()
+ ) {
AnimatedContent(
targetState = carouselState.activeSlideIndex,
transitionSpec = { enterTransition.with(exitTransition) }
) {
LaunchedEffect(Unit) {
this@AnimatedContent.onAnimationCompletion {
- if (isAutoScrollActive.not()) {
+ // Outer box is focused
+ if (!isAutoScrollActive && focusState?.isFocused == true) {
carouselOuterBoxFocusRequester.requestFocus()
focusManager.moveFocus(FocusDirection.Enter)
}
@@ -163,7 +167,7 @@
private fun shouldPerformAutoScroll(focusState: FocusState?): Boolean {
val carouselIsFocused = focusState?.isFocused ?: false
val carouselHasFocus = focusState?.hasFocus ?: false
- return (carouselIsFocused || carouselHasFocus).not()
+ return !(carouselIsFocused || carouselHasFocus)
}
@Suppress("IllegalExperimentalApiUsage")
@@ -182,19 +186,16 @@
doAutoScroll: Boolean,
onAutoScrollChange: (isAutoScrollActive: Boolean) -> Unit = {},
) {
- val currentTimeToDisplaySlideMillis by rememberUpdatedState(timeToDisplaySlideMillis)
- val currentSlideCount by rememberUpdatedState(slideCount)
-
if (doAutoScroll) {
LaunchedEffect(carouselState) {
while (true) {
yield()
- delay(currentTimeToDisplaySlideMillis)
+ delay(timeToDisplaySlideMillis)
if (carouselState.activePauseHandlesCount > 0) {
snapshotFlow { carouselState.activePauseHandlesCount }
.first { pauseHandleCount -> pauseHandleCount == 0 }
}
- carouselState.moveToNextSlide(currentSlideCount)
+ carouselState.moveToNextSlide(slideCount)
}
}
}
@@ -203,50 +204,58 @@
@Suppress("IllegalExperimentalApiUsage")
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
-private fun Modifier.manualScrolling(
+private fun Modifier.handleKeyEvents(
carouselState: CarouselState,
+ outerBoxFocusRequester: FocusRequester,
+ focusManager: FocusManager,
slideCount: Int,
isLtr: Boolean
-): Modifier =
- this.focusProperties {
- exit = {
- val showPreviousSlideAndGetFocusRequester = {
- if (carouselState.isFirstSlide().not()) {
- carouselState.moveToPreviousSlide(slideCount)
- FocusRequester.Cancel
- } else {
- FocusRequester.Default
- }
- }
- val showNextSlideAndGetFocusRequester = {
- if (carouselState.isLastSlide(slideCount).not()) {
- carouselState.moveToNextSlide(slideCount)
- FocusRequester.Cancel
- } else {
- FocusRequester.Default
- }
- }
- when (it) {
- FocusDirection.Left -> {
- if (isLtr) {
- showPreviousSlideAndGetFocusRequester()
- } else {
- showNextSlideAndGetFocusRequester()
- }
- }
+): Modifier = onKeyEvent {
+ // Ignore KeyUp action type
+ if (it.type == KeyUp) {
+ return@onKeyEvent KeyEventPropagation.ContinuePropagation
+ }
- FocusDirection.Right -> {
- if (isLtr) {
- showNextSlideAndGetFocusRequester()
- } else {
- showPreviousSlideAndGetFocusRequester()
- }
- }
-
- else -> FocusRequester.Default
- }
+ val showPreviousSlideAndGetKeyEventPropagation = {
+ if (carouselState.isFirstSlide()) {
+ KeyEventPropagation.ContinuePropagation
+ } else {
+ carouselState.moveToPreviousSlide(slideCount)
+ outerBoxFocusRequester.requestFocus()
+ KeyEventPropagation.StopPropagation
}
}
+ val showNextSlideAndGetKeyEventPropagation = {
+ if (carouselState.isLastSlide(slideCount)) {
+ KeyEventPropagation.ContinuePropagation
+ } else {
+ carouselState.moveToNextSlide(slideCount)
+ outerBoxFocusRequester.requestFocus()
+ KeyEventPropagation.StopPropagation
+ }
+ }
+
+ when (it.key.nativeKeyCode) {
+ KEYCODE_BACK -> {
+ focusManager.moveFocus(FocusDirection.Exit)
+ KeyEventPropagation.ContinuePropagation
+ }
+
+ KEYCODE_DPAD_LEFT -> if (isLtr) {
+ showPreviousSlideAndGetKeyEventPropagation()
+ } else {
+ showNextSlideAndGetKeyEventPropagation()
+ }
+
+ KEYCODE_DPAD_RIGHT -> if (isLtr) {
+ showNextSlideAndGetKeyEventPropagation()
+ } else {
+ showPreviousSlideAndGetKeyEventPropagation()
+ }
+
+ else -> KeyEventPropagation.ContinuePropagation
+ }
+}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@@ -332,9 +341,11 @@
@OptIn(ExperimentalTvMaterial3Api::class)
internal class ScrollPauseHandleImpl(private val carouselState: CarouselState) : ScrollPauseHandle {
private var active by mutableStateOf(true)
+
init {
carouselState.activePauseHandlesCount += 1
}
+
/**
* Resumes the auto-scroll behaviour if there are no other active [ScrollPauseHandle]s.
*/
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/CarouselItem.kt b/tv/tv-material/src/main/java/androidx/tv/material3/CarouselItem.kt
index 0b10e6f..96114ca 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/CarouselItem.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/CarouselItem.kt
@@ -76,24 +76,27 @@
overlay: @Composable () -> Unit
) {
val overlayVisible = remember { MutableTransitionState(initialState = false) }
- var focusState: FocusState? by remember { mutableStateOf(null) }
+ var containerBoxFocusState: FocusState? by remember { mutableStateOf(null) }
val focusManager = LocalFocusManager.current
var exitFocus by remember { mutableStateOf(false) }
LaunchedEffect(overlayVisible) {
overlayVisible.onAnimationCompletion {
// slide has loaded completely.
- if (focusState?.isFocused == true) { focusManager.moveFocus(FocusDirection.Enter) }
+ if (containerBoxFocusState?.isFocused == true) {
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
}
}
+ // This box holds the focus until the overlay animation completes
Box(modifier = modifier
.onKeyEvent {
exitFocus = it.key.nativeKeyCode == KeyEvent.KEYCODE_BACK && it.type == KeyDown
false
}
.onFocusChanged {
- focusState = it
+ containerBoxFocusState = it
if (it.isFocused && exitFocus) {
focusManager.moveFocus(FocusDirection.Exit)
exitFocus = false
@@ -101,7 +104,8 @@
focusManager.moveFocus(FocusDirection.Enter)
}
}
- .focusable()) {
+ .focusable()
+ ) {
background()
LaunchedEffect(overlayVisible) {
@@ -112,8 +116,7 @@
}
AnimatedVisibility(
- modifier = Modifier
- .align(Alignment.BottomStart),
+ modifier = Modifier.align(Alignment.BottomStart),
visibleState = overlayVisible,
enter = overlayEnterTransition,
exit = overlayExitTransition
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/KeyEventPropagation.kt b/tv/tv-material/src/main/java/androidx/tv/material3/KeyEventPropagation.kt
new file mode 100644
index 0000000..a5d8ce8
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/KeyEventPropagation.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.tv.material3
+
+internal object KeyEventPropagation {
+ const val StopPropagation = true
+ const val ContinuePropagation = false
+}