Revert "Revert "Featured Carousel feature in TV Compose.""
This reverts commit ab6c4b828736c71a91f14f81d8a45ff84503ea03.
Reason for revert: Reintroducing the Feature Carousel with code updated to
- use FocusDirection.Enter instead of FocusDirection.In
- cleaning up build.gradle as per the suggestion
- updating the test to fix the failure
Test: Tested manually by creating a sample app and have written
Integration Tests as well.
Relnote: "Adding the feature-carousel component to
tv-compose library"
Change-Id: I7b629ac1bb828844ba6282a3dd1eb577dd994429
diff --git a/settings.gradle b/settings.gradle
index eea363e..f5a6dcc 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -861,6 +861,7 @@
includeProject(":transition:transition-ktx", [BuildType.MAIN, BuildType.FLAN])
includeProject(":tv:tv-foundation", [BuildType.COMPOSE])
includeProject(":tv:tv-material", [BuildType.COMPOSE])
+includeProject(":tv:tv-material-samples", "tv/tv-material/samples", [BuildType.COMPOSE])
includeProject(":tvprovider:tvprovider", [BuildType.MAIN])
includeProject(":vectordrawable:integration-tests:testapp", [BuildType.MAIN])
includeProject(":vectordrawable:vectordrawable", [BuildType.MAIN])
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
index c8dd293..2584bf1 100644
--- a/tv/tv-foundation/build.gradle
+++ b/tv/tv-foundation/build.gradle
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import androidx.build.AndroidXComposePlugin
import androidx.build.LibraryType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@@ -31,24 +30,23 @@
dependencies {
api(libs.kotlinStdlib)
- api("androidx.annotation:annotation:1.1.0")
- api(project(":compose:animation:animation"))
- api(project(':compose:runtime:runtime'))
- api(project(":compose:ui:ui"))
+ def composeVersion = '1.2.1'
+
+ api("androidx.annotation:annotation:1.4.0")
+ api("androidx.compose.animation:animation:$composeVersion")
+ api("androidx.compose.runtime:runtime:$composeVersion")
+ api("androidx.compose.ui:ui:$composeVersion")
implementation(libs.kotlinStdlibCommon)
+ implementation("androidx.compose.foundation:foundation:$composeVersion")
implementation(project(":compose:foundation:foundation"))
- implementation(project(":compose:foundation:foundation-layout"))
- implementation(project(":compose:ui:ui-graphics"))
- implementation(project(":compose:ui:ui-text"))
- implementation(project(":compose:ui:ui-util"))
- implementation("androidx.profileinstaller:profileinstaller:1.2.0-alpha02")
+ implementation("androidx.compose.foundation:foundation-layout:$composeVersion")
+ implementation("androidx.compose.ui:ui-graphics:$composeVersion")
+ implementation("androidx.compose.ui:ui-text:$composeVersion")
+ implementation("androidx.compose.ui:ui-util:$composeVersion")
+ implementation("androidx.profileinstaller:profileinstaller:1.2.0")
- testImplementation(libs.testRules)
- testImplementation(libs.testRunner)
- testImplementation(libs.junit)
- implementation(libs.truth)
-
+ androidTestImplementation(libs.truth)
androidTestImplementation(project(":compose:ui:ui-test"))
androidTestImplementation(project(":compose:ui:ui-test-junit4"))
androidTestImplementation(project(":compose:test-utils"))
@@ -58,10 +56,8 @@
android {
namespace "androidx.tv.foundation"
defaultConfig {
- minSdkVersion 28
+ minSdkVersion 21
}
- // Use Robolectric 4.+
- testOptions.unitTests.includeAndroidResources = true
lintOptions {
disable 'IllegalExperimentalApiUsage' // TODO (b/233188423): Address before moving to beta
}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
index 37862be..3328e28 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
@@ -95,12 +95,10 @@
* be recomposed on every change causing potential performance issues.
*
* If you want to run some side effects like sending an analytics event or updating a state
- * based on this value consider using "snapshotFlow":
- * @sample androidx.compose.foundation.samples.UsingListScrollPositionForSideEffectSample
+ * based on this value consider using "snapshotFlow".
*
* If you need to use it in the composition then consider wrapping the calculation into a
- * derived state in order to only have recompositions when the derived value changes:
- * @sample androidx.compose.foundation.samples.UsingListScrollPositionInCompositionSample
+ * derived state in order to only have recompositions when the derived value changes.
*/
val firstVisibleItemIndex: Int get() = scrollPosition.index.value
@@ -127,8 +125,7 @@
* Therefore, avoid using it in the composition.
*
* If you want to run some side effects like sending an analytics event or updating a state
- * based on this value consider using "snapshotFlow":
- * @sample androidx.compose.foundation.samples.UsingListLayoutInfoForSideEffectSample
+ * based on this value consider using "snapshotFlow"
*/
val layoutInfo: TvLazyListLayoutInfo get() = layoutInfoState.value
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt
index 441895d..59f42ba 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt
@@ -83,8 +83,6 @@
* enable item reordering animations. Aside from item reordering all other position changes
* caused by events like arrangement or alignment changes will also be animated.
*
- * @sample androidx.compose.foundation.samples.ItemPlacementAnimationSample
- *
* @param animationSpec a finite animation that will be used to animate the item placement.
*/
@ExperimentalFoundationApi
diff --git a/tv/tv-material/api/current.txt b/tv/tv-material/api/current.txt
index e6f50d0..0ed6747 100644
--- a/tv/tv-material/api/current.txt
+++ b/tv/tv-material/api/current.txt
@@ -1 +1,18 @@
// Signature format: 4.0
+package androidx.tv.material.carousel {
+
+ public final class CarouselItemKt {
+ }
+
+ public final class CarouselKt {
+ }
+
+}
+
+package androidx.tv.material.pager {
+
+ public final class PagerKt {
+ }
+
+}
+
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index e6f50d0..4e063ddc4 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -1 +1,59 @@
// Signature format: 4.0
+package androidx.tv.material {
+
+ @kotlin.RequiresOptIn(message="This tv-material API is experimental and likely to change or be removed in the future.") public @interface ExperimentalTvMaterialApi {
+ }
+
+}
+
+package androidx.tv.material.carousel {
+
+ @androidx.tv.material.ExperimentalTvMaterialApi public final class CarouselDefaults {
+ method @androidx.compose.runtime.Composable @androidx.tv.material.ExperimentalTvMaterialApi public void Indicator(androidx.tv.material.carousel.CarouselState carouselState, int slideCount, optional androidx.compose.ui.Modifier modifier);
+ method public androidx.compose.animation.EnterTransition getEnterTransition();
+ method public androidx.compose.animation.ExitTransition getExitTransition();
+ method public long getTimeToDisplaySlideMillis();
+ property public final androidx.compose.animation.EnterTransition EnterTransition;
+ property public final androidx.compose.animation.ExitTransition ExitTransition;
+ property public final long TimeToDisplaySlideMillis;
+ field public static final androidx.tv.material.carousel.CarouselDefaults INSTANCE;
+ }
+
+ @androidx.tv.material.ExperimentalTvMaterialApi public final class CarouselItemDefaults {
+ method public androidx.compose.animation.EnterTransition getOverlayEnterTransition();
+ method public long getOverlayEnterTransitionStartDelayMillis();
+ method public androidx.compose.animation.ExitTransition getOverlayExitTransition();
+ property public final androidx.compose.animation.EnterTransition OverlayEnterTransition;
+ property public final long OverlayEnterTransitionStartDelayMillis;
+ property public final androidx.compose.animation.ExitTransition OverlayExitTransition;
+ field public static final androidx.tv.material.carousel.CarouselItemDefaults INSTANCE;
+ }
+
+ public final class CarouselItemKt {
+ method @androidx.compose.runtime.Composable @androidx.tv.material.ExperimentalTvMaterialApi public static void CarouselItem(kotlin.jvm.functions.Function0<kotlin.Unit> background, optional androidx.compose.ui.Modifier modifier, optional long overlayEnterTransitionStartDelayMillis, optional androidx.compose.animation.EnterTransition overlayEnterTransition, optional androidx.compose.animation.ExitTransition overlayExitTransition, kotlin.jvm.functions.Function0<kotlin.Unit> overlay);
+ }
+
+ public final class CarouselKt {
+ method @androidx.compose.runtime.Composable @androidx.tv.material.ExperimentalTvMaterialApi public static void Carousel(int slideCount, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.material.carousel.CarouselState carouselState, optional long timeToDisplaySlideMillis, optional androidx.compose.animation.EnterTransition enterTransition, optional androidx.compose.animation.ExitTransition exitTransition, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> carouselIndicator, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> content);
+ }
+
+ @androidx.compose.runtime.Stable @androidx.tv.material.ExperimentalTvMaterialApi public final class CarouselState {
+ ctor public CarouselState(optional int initialSlideIndex);
+ method public int getSlideIndex();
+ method public androidx.tv.material.carousel.ScrollPauseHandle pauseAutoScroll(int slideIndex);
+ property public final int slideIndex;
+ }
+
+ @androidx.tv.material.ExperimentalTvMaterialApi public sealed interface ScrollPauseHandle {
+ method public void resumeAutoScroll();
+ }
+
+}
+
+package androidx.tv.material.pager {
+
+ public final class PagerKt {
+ }
+
+}
+
diff --git a/tv/tv-material/api/restricted_current.txt b/tv/tv-material/api/restricted_current.txt
index e6f50d0..0ed6747 100644
--- a/tv/tv-material/api/restricted_current.txt
+++ b/tv/tv-material/api/restricted_current.txt
@@ -1 +1,18 @@
// Signature format: 4.0
+package androidx.tv.material.carousel {
+
+ public final class CarouselItemKt {
+ }
+
+ public final class CarouselKt {
+ }
+
+}
+
+package androidx.tv.material.pager {
+
+ public final class PagerKt {
+ }
+
+}
+
diff --git a/tv/tv-material/build.gradle b/tv/tv-material/build.gradle
index 68c20e2..3e5244d 100644
--- a/tv/tv-material/build.gradle
+++ b/tv/tv-material/build.gradle
@@ -15,20 +15,48 @@
*/
import androidx.build.LibraryType
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("AndroidXPlugin")
+ id("AndroidXComposePlugin")
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
dependencies {
api(libs.kotlinStdlib)
- // Add dependencies here
+
+ def composeVersion = '1.2.1'
+
+ api("androidx.annotation:annotation:1.4.0")
+ api("androidx.compose.animation:animation:$composeVersion")
+ api("androidx.compose.runtime:runtime:$composeVersion")
+
+ implementation(libs.kotlinStdlibCommon)
+ implementation("androidx.compose.foundation:foundation:$composeVersion")
+ implementation("androidx.compose.foundation:foundation-layout:$composeVersion")
+ implementation(project(":compose:ui:ui"))
+ implementation("androidx.compose.ui:ui-graphics:$composeVersion")
+ implementation("androidx.compose.ui:ui-text:$composeVersion")
+ implementation("androidx.compose.ui:ui-util:$composeVersion")
+ implementation("androidx.profileinstaller:profileinstaller:1.2.0")
+
+ androidTestImplementation(libs.truth)
+
+ androidTestImplementation(project(":compose:ui:ui-test"))
+ androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+ androidTestImplementation(project(":compose:test-utils"))
+ androidTestImplementation(libs.testRunner)
+
+ samples(projectOrArtifact(":tv:tv-material-samples"))
}
android {
namespace "androidx.tv.material"
+ defaultConfig {
+ minSdkVersion 21
+ }
}
androidx {
@@ -37,4 +65,13 @@
mavenGroup = LibraryGroups.TV
inceptionYear = "2022"
description = "build TV applications using controls that adhere to Material Design Language."
+ targetsJavaConsumers = false
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions {
+ freeCompilerArgs += [
+ "-Xjvm-default=all",
+ ]
+ }
}
diff --git a/tv/tv-material/samples/build.gradle b/tv/tv-material/samples/build.gradle
new file mode 100644
index 0000000..f78e2c8
--- /dev/null
+++ b/tv/tv-material/samples/build.gradle
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 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.
+ */
+
+import androidx.build.LibraryType
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.application")
+ id("AndroidXComposePlugin")
+ id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+ implementation(project(":tv:tv-material"))
+ implementation(libs.kotlinStdlib)
+ implementation("androidx.leanback:leanback:1.0.0")
+ implementation(project(":activity:activity"))
+ implementation(project(":compose:material3:material3"))
+ implementation(project(":navigation:navigation-runtime"))
+ implementation("androidx.activity:activity-compose:1.5.1")
+ implementation(project(":tv:tv-foundation"))
+ implementation("androidx.appcompat:appcompat:1.6.0-alpha05")
+}
+
+androidx {
+ name = "TV-Compose Samples"
+ type = LibraryType.SAMPLES
+ mavenGroup = LibraryGroups.TV
+ inceptionYear = "2022"
+ description = "Contains the sample code for the APIs in the androidx.tv:tv-material library"
+}
+
+android {
+ defaultConfig {
+ minSdkVersion 28
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+ }
+ namespace "androidx.tv.tvmaterial.samples"
+}
diff --git a/tv/tv-material/samples/src/main/AndroidManifest.xml b/tv/tv-material/samples/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4c801cb
--- /dev/null
+++ b/tv/tv-material/samples/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2022 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <application
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.Androidx">
+ <activity
+ android:name=".MainActivity"
+ android:banner="@drawable/app_icon_your_company"
+ android:exported="true"
+ android:icon="@drawable/app_icon_your_company"
+ android:label="@string/app_name"
+ android:logo="@drawable/app_icon_your_company"
+ android:screenOrientation="landscape">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ </application>
+
+ <uses-feature
+ android:name="android.software.leanback"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.touchscreen"
+ android:required="false" />
+
+ <uses-permission android:name="android.permission.INTERNET" />
+
+</manifest>
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt
new file mode 100644
index 0000000..0a19b0c
--- /dev/null
+++ b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/FeaturedCarousel.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2022 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.tvmaterial.samples
+
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Color.Companion.Cyan
+import androidx.compose.ui.graphics.Color.Companion.Gray
+import androidx.compose.ui.graphics.Color.Companion.Red
+import androidx.compose.ui.graphics.Color.Companion.Yellow
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.tv.material.ExperimentalTvMaterialApi
+import androidx.tv.material.carousel.Carousel
+import androidx.tv.material.carousel.CarouselItem
+import androidx.tv.material.carousel.CarouselState
+
+@OptIn(ExperimentalTvMaterialApi::class)
+@Composable
+fun FeaturedCarousel() {
+ val carouselState = remember { CarouselState(0) }
+ LazyColumn {
+ item {
+ Carousel(
+ modifier = Modifier
+ .height(400.dp)
+ .width(950.dp),
+ carouselState = carouselState,
+ slideCount = 3
+ ) { SampleFrame(it) }
+ }
+
+ items(7) { SampleLazyRow() }
+ }
+}
+
+@OptIn(ExperimentalTvMaterialApi::class)
+@Composable
+fun SampleFrame(idx: Int) {
+ val item = mediaItems[idx]
+
+ CarouselItem(
+ background = {
+ Box(
+ Modifier.background(item.backgroundColor).fillMaxSize()
+ )
+ }) {
+ Box {
+ Column(modifier = Modifier.align(Alignment.BottomStart)) {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = Color.Black,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = item.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.Black,
+ fontWeight = FontWeight.Normal,
+ modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp)
+ )
+
+ Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ SampleButton(text = "PLAY")
+ SampleButton(text = "INFO")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SampleButton(text: String) {
+ var cardScale
+ by remember { mutableStateOf(0.5f) }
+ val borderGlowColorTransition =
+ rememberInfiniteTransition()
+ var initialValue
+ by remember { mutableStateOf(Color.Transparent) }
+ val glowingColor
+ by borderGlowColorTransition.animateColor(
+ initialValue = initialValue,
+ targetValue = Color.Transparent,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1000, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+
+ Button(
+ onClick = {},
+ modifier = Modifier
+ .scale(cardScale)
+ .border(
+ 2.dp, glowingColor,
+ RoundedCornerShape(12.dp)
+ )
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ cardScale = 1.0f
+ initialValue = Color.White
+ } else {
+ cardScale = 0.5f
+ initialValue = Color.Transparent
+ }
+ }) {
+ Text(text = text)
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleLazyRow() {
+ LazyRow(
+ state = rememberLazyListState(),
+ contentPadding = PaddingValues(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)) {
+ items((1..10).map { it.toString() }) {
+ var cardScale by remember { mutableStateOf(0.5f) }
+ val borderGlowColorTransition = rememberInfiniteTransition()
+ var initialValue by remember { mutableStateOf(Color.Transparent) }
+ val glowingColor by borderGlowColorTransition.animateColor(
+ initialValue = initialValue,
+ targetValue = Color.Transparent,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1000, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+
+ Card(
+ modifier = Modifier
+ .width(100.dp)
+ .height(100.dp)
+ .scale(cardScale)
+ .border(2.dp, glowingColor, RoundedCornerShape(12.dp))
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ cardScale = 1.0f
+ initialValue = Color.White
+ } else {
+ cardScale = 0.5f
+ initialValue = Color.Transparent
+ }
+ }
+ .focusable()
+ ) {
+ Text(
+ text = it,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .padding(12.dp),
+ color = Red,
+ fontWeight = FontWeight.Bold
+
+ )
+ }
+ }
+ }
+}
+
+val mediaItems = listOf(
+ Media(id = "1", title = "Title 1", description = "Description 1", backgroundColor = Gray),
+ Media(id = "2", title = "Title 2", description = "Description 2", backgroundColor = Yellow),
+ Media(id = "3", title = "Title 3", description = "Description 3", backgroundColor = Cyan)
+)
diff --git a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/MainActivity.kt b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/MainActivity.kt
new file mode 100644
index 0000000..c329df0
--- /dev/null
+++ b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/MainActivity.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 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.tvmaterial.samples
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ // A surface container using the 'background' color from the theme
+ Surface(color = MaterialTheme.colorScheme.background) {
+ FeaturedCarousel()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/Media.kt b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/Media.kt
new file mode 100644
index 0000000..dfb2685
--- /dev/null
+++ b/tv/tv-material/samples/src/main/java/androidx/tv/tvmaterial/samples/Media.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 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.tvmaterial.samples
+
+import androidx.compose.ui.graphics.Color
+
+data class Media(
+ val id: String,
+ val title: String,
+ val description: String,
+ val backgroundColor: Color
+)
diff --git a/tv/tv-material/samples/src/main/res/drawable/app_icon_your_company.png b/tv/tv-material/samples/src/main/res/drawable/app_icon_your_company.png
new file mode 100644
index 0000000..0a47b01
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/drawable/app_icon_your_company.png
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/layout/activity_main.xml b/tv/tv-material/samples/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..2a1b45b
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/layout/activity_main.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2022 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.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/main_browse_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:context=".MainActivity"
+ tools:deviceIds="tv"
+ tools:ignore="MergeRootFrame" />
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/res/mipmap-hdpi/ic_launcher.webp b/tv/tv-material/samples/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/mipmap-hdpi/ic_launcher.webp
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/mipmap-mdpi/ic_launcher.webp b/tv/tv-material/samples/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/mipmap-mdpi/ic_launcher.webp
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/mipmap-xhdpi/ic_launcher.webp b/tv/tv-material/samples/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/mipmap-xhdpi/ic_launcher.webp
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/tv/tv-material/samples/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/tv/tv-material/samples/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Binary files differ
diff --git a/tv/tv-material/samples/src/main/res/values/colors.xml b/tv/tv-material/samples/src/main/res/values/colors.xml
new file mode 100644
index 0000000..733f3f5
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+<resources>
+ <color name="background_gradient_start">#000000</color>
+ <color name="background_gradient_end">#DDDDDD</color>
+ <color name="fastlane_background">#0096a6</color>
+ <color name="search_opaque">#ffaa3f</color>
+ <color name="selected_background">#ffaa3f</color>
+ <color name="default_background">#3d3d3d</color>
+</resources>
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/res/values/strings.xml b/tv/tv-material/samples/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2d22ec3
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/values/strings.xml
@@ -0,0 +1,35 @@
+<!--
+ Copyright 2022 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.
+ -->
+
+<resources>
+ <string name="app_name">Samples</string>
+ <string name="browse_title">Videos by Your Company</string>
+ <string name="related_movies">Related Videos</string>
+ <string name="grid_view">Grid View</string>
+ <string name="error_fragment">Error Fragment</string>
+ <string name="personal_settings">Personal Settings</string>
+ <string name="watch_trailer_1">Watch trailer</string>
+ <string name="watch_trailer_2">FREE</string>
+ <string name="rent_1">Rent By Day</string>
+ <string name="rent_2">From $1.99</string>
+ <string name="buy_1">Buy and Own</string>
+ <string name="buy_2">AT $9.99</string>
+ <string name="movie">Movie</string>
+
+ <!-- Error messages -->
+ <string name="error_fragment_message">An error occurred</string>
+ <string name="dismiss_error">Dismiss</string>
+</resources>
\ No newline at end of file
diff --git a/tv/tv-material/samples/src/main/res/values/themes.xml b/tv/tv-material/samples/src/main/res/values/themes.xml
new file mode 100644
index 0000000..402d3ea
--- /dev/null
+++ b/tv/tv-material/samples/src/main/res/values/themes.xml
@@ -0,0 +1,19 @@
+<!--
+ Copyright 2022 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.
+ -->
+
+<resources>
+ <style name="Theme.Androidx" parent="@style/Theme.AppCompat" />
+</resources>
\ No newline at end of file
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselItemTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselItemTest.kt
new file mode 100644
index 0000000..25d05ef
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselItemTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 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.material.carousel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.tv.material.ExperimentalTvMaterialApi
+import org.junit.Rule
+import org.junit.Test
+
+class CarouselItemTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @OptIn(ExperimentalTvMaterialApi::class)
+ @Test
+ fun carouselItem_overlayVisibleAfterRenderTime() {
+ val overlayEnterTransitionStartDelay: Long = 2000
+ val overlayTag = "overlay"
+ val backgroundTag = "background"
+ rule.setContent {
+ CarouselItem(
+ overlayEnterTransitionStartDelayMillis = overlayEnterTransitionStartDelay,
+ background = {
+ Box(
+ Modifier
+ .testTag(backgroundTag)
+ .size(200.dp)
+ .background(Color.Blue)) }) {
+ Box(
+ Modifier
+ .testTag(overlayTag)
+ .size(50.dp)
+ .background(Color.Red))
+ }
+ }
+
+ // only background is visible
+ rule.onNodeWithTag(backgroundTag).assertExists()
+ rule.onNodeWithTag(overlayTag).assertDoesNotExist()
+
+ // advance clock by `overlayEnterTransitionStartDelay`
+ rule.mainClock.advanceTimeBy(overlayEnterTransitionStartDelay)
+
+ rule.onNodeWithTag(backgroundTag).assertExists()
+ rule.onNodeWithTag(overlayTag).assertExists()
+ }
+}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt
new file mode 100644
index 0000000..00d4043
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material/carousel/CarouselTest.kt
@@ -0,0 +1,484 @@
+/*
+ * Copyright 2022 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.material.carousel
+
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertIsNotFocused
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onParent
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.unit.dp
+import androidx.tv.material.ExperimentalTvMaterialApi
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalTvMaterialApi::class)
+class CarouselTest {
+
+ private val delayBetweenSlides = 2500L
+ private val animationTime = 900L
+ private val overlayRenderWaitTime = 1500L
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun carousel_autoScrolls() {
+
+ rule.setContent {
+ Content()
+ }
+
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+ rule.onNodeWithText("Text 2").assertIsDisplayed()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+ rule.onNodeWithText("Text 3").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_onFocus_stopsScroll() {
+
+ rule.setContent {
+ Content()
+ }
+
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("Text 1").onParent().assertIsNotFocused()
+
+ rule.onNodeWithText("Text 1")
+ .onParent()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").onParent().assertIsFocused()
+ }
+
+ @Test
+ fun carousel_onUserTriggeredPause_stopsScroll() {
+ var carouselState: CarouselState?
+ rule.setContent {
+ carouselState = remember { CarouselState() }
+ Content(
+ carouselState = carouselState!!,
+ content = {
+ BasicText(text = "Text ${it + 1}")
+ LaunchedEffect(carouselState) { carouselState?.pauseAutoScroll(it) }
+ })
+ }
+
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("Text 1").onParent().assertIsNotFocused()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_onUserTriggeredPauseAndResume_resumeScroll() {
+ var carouselState: CarouselState?
+ var pauseHandle: ScrollPauseHandle? = null
+ rule.setContent {
+ carouselState = remember { CarouselState() }
+ Content(
+ carouselState = carouselState!!,
+ content = {
+ BasicText(text = "Text ${it + 1}")
+ LaunchedEffect(carouselState) {
+ pauseHandle = carouselState?.pauseAutoScroll(it)
+ }
+ })
+ }
+
+ rule.mainClock.autoAdvance = false
+
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("Text 1").onParent().assertIsNotFocused()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // pause handle has not been resumed, so Text 1 should still be on the screen.
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+
+ rule.runOnIdle { pauseHandle?.resumeAutoScroll() }
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // pause handle has been resumed, so Text 2 should be on the screen after
+ // delayBetweenSlides + animationTime
+ rule.onNodeWithText("Text 1").assertDoesNotExist()
+ rule.onNodeWithText("Text 2").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_onMultipleUserTriggeredPauseAndResume_resumesScroll() {
+ var carouselState: CarouselState?
+ var pauseHandle1: ScrollPauseHandle? = null
+ var pauseHandle2: ScrollPauseHandle? = null
+ rule.setContent {
+ carouselState = remember { CarouselState() }
+ Content(
+ carouselState = carouselState!!,
+ content = {
+ BasicText(text = "Text ${it + 1}")
+ LaunchedEffect(carouselState) {
+ if (pauseHandle1 == null) {
+ pauseHandle1 = carouselState?.pauseAutoScroll(it)
+ }
+ if (pauseHandle2 == null) {
+ pauseHandle2 = carouselState?.pauseAutoScroll(it)
+ }
+ }
+ })
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("Text 1").onParent().assertIsNotFocused()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // pause handles have not been resumed, so Text 1 should still be on the screen.
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+
+ rule.runOnIdle { pauseHandle1?.resumeAutoScroll() }
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // Second pause handle has not been resumed, so Text 1 should still be on the screen.
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+
+ rule.runOnIdle { pauseHandle2?.resumeAutoScroll() }
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+ // All pause handles have been resumed, so Text 2 should be on the screen after
+ // delayBetweenSlides + animationTime
+ rule.onNodeWithText("Text 1").assertDoesNotExist()
+ rule.onNodeWithText("Text 2").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_onRepeatedResumesOnSamePauseHandle_ignoresSubsequentResumeCalls() {
+ var carouselState: CarouselState?
+ var pauseHandle1: ScrollPauseHandle? = null
+ var pauseHandle2: ScrollPauseHandle? = null
+ rule.setContent {
+ carouselState = remember { CarouselState() }
+ Content(
+ carouselState = carouselState!!,
+ content = {
+ BasicText(text = "Text ${it + 1}")
+ LaunchedEffect(carouselState) {
+ if (pauseHandle1 == null) {
+ pauseHandle1 = carouselState?.pauseAutoScroll(it)
+ }
+ if (pauseHandle2 == null) {
+ pauseHandle2 = carouselState?.pauseAutoScroll(it)
+ }
+ }
+ })
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("Text 1").onParent().assertIsNotFocused()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // pause handles have not been resumed, so Text 1 should still be on the screen.
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+
+ rule.runOnIdle { pauseHandle1?.resumeAutoScroll() }
+ // subsequent call to resume should be ignored
+ rule.runOnIdle { pauseHandle1?.resumeAutoScroll() }
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+
+ // Second pause handle has not been resumed, so Text 1 should still be on the screen.
+ rule.onNodeWithText("Text 2").assertDoesNotExist()
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_outOfFocus_resumesScroll() {
+ rule.setContent {
+ Content()
+ }
+
+ rule.onNodeWithText("Text 1")
+ .onParent()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+
+ rule.onNodeWithText("Card").performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.onNodeWithText("Card").assertIsFocused()
+
+ rule.mainClock.advanceTimeBy(delayBetweenSlides)
+ rule.mainClock.advanceTimeBy(animationTime)
+ rule.onNodeWithText("Text 1").assertDoesNotExist()
+ rule.onNodeWithText("Text 2").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_pagerIndicatorDisplayed() {
+ rule.setContent {
+ Content()
+ }
+
+ rule.onNodeWithTag("indicator").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_withAnimatedContent_successfulTransition() {
+ rule.setContent {
+ AnimatedContent()
+ }
+
+ rule.onNodeWithText("Text 1").assertDoesNotExist()
+
+ rule.mainClock.advanceTimeBy(overlayRenderWaitTime + animationTime, true)
+ rule.mainClock.advanceTimeByFrame()
+
+ rule.onNodeWithText("Text 1").assertIsDisplayed()
+ rule.onNodeWithText("PLAY").assertIsDisplayed()
+ }
+
+ @Test
+ fun carousel_withAnimatedContent_successfulFocusIn() {
+ rule.setContent {
+ AnimatedContent()
+ }
+
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag("pager")
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+
+ // current slide overlay render delay
+ rule.mainClock.advanceTimeBy(animationTime + overlayRenderWaitTime, false)
+ rule.mainClock.advanceTimeBy(animationTime, false)
+ rule.mainClock.advanceTimeByFrame()
+
+ rule.onNodeWithText("PLAY").assertIsDisplayed()
+ rule.onNodeWithText("PLAY").assertIsFocused()
+ }
+
+ @Composable
+ fun Content(
+ carouselState: CarouselState = remember { CarouselState() },
+ content: @Composable (index: Int) -> Unit = { BasicText(text = "Text ${it + 1}")
+ }
+ ) {
+ val slideCount = 3
+ LazyColumn {
+ item {
+ Carousel(
+ modifier = Modifier.fillMaxSize(),
+ carouselState = carouselState,
+ slideCount = slideCount,
+ timeToDisplaySlideMillis = delayBetweenSlides,
+ carouselIndicator = {
+ CarouselDefaults.Indicator(modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ .testTag("indicator"),
+ carouselState = carouselState,
+ slideCount = slideCount)
+ },
+ content = content
+ )
+ }
+ item {
+ Box(modifier = Modifier.focusable()
+ ) {
+ BasicText(
+ text = "Card",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .padding(12.dp)
+ .focusable()
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun AnimatedContent(carouselState: CarouselState = remember { CarouselState() }) {
+ LazyColumn {
+ item {
+ Carousel(
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag("pager"),
+ slideCount = 3,
+ timeToDisplaySlideMillis = delayBetweenSlides,
+ carouselState = carouselState
+ ) { Frame(text = "Text ${it + 1}") }
+ }
+ item {
+ Box(modifier = Modifier.focusable()
+ ) {
+ BasicText(
+ text = "Card",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .padding(12.dp)
+ .focusable()
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun Frame(text: String) {
+ val focusRequester = FocusRequester()
+ CarouselItem(
+ overlayEnterTransitionStartDelayMillis = overlayRenderWaitTime,
+ background = {}) {
+ Column(modifier = Modifier
+ .onFocusChanged {
+ if (it.isFocused) {
+ focusRequester.requestFocus()
+ }
+ }
+ .focusable()) {
+ BasicText(text = text)
+ Row(modifier = Modifier
+ .horizontalScroll(rememberScrollState())
+ .onFocusChanged {
+ if (it.isFocused) {
+ focusRequester.requestFocus()
+ }
+ }
+ .focusable()) {
+ TestButton(text = "PLAY", focusRequester)
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun TestButton(text: String, focusRequester: FocusRequester? = null) {
+ var cardScale
+ by remember { mutableStateOf(0.5f) }
+ val borderGlowColorTransition =
+ rememberInfiniteTransition()
+ var initialValue
+ by remember { mutableStateOf(Color.Transparent) }
+ val glowingColor
+ by borderGlowColorTransition.animateColor(
+ initialValue = initialValue,
+ targetValue = Color.Transparent,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1000,
+ easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+
+ val baseModifier =
+ if (focusRequester == null) Modifier else Modifier.focusRequester(focusRequester)
+
+ Box(
+ modifier = baseModifier
+ .scale(cardScale)
+ .border(
+ 2.dp, glowingColor,
+ RoundedCornerShape(12.dp)
+ )
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ cardScale = 1.0f
+ initialValue = Color.White
+ } else {
+ cardScale = 0.5f
+ initialValue = Color.Transparent
+ }
+ }
+ .clickable(onClick = {})) {
+ BasicText(text = text)
+ }
+ }
+
+ @Test
+ fun carousel_zeroSlideCount_drawsSomething() {
+ val testTag = "emptyCarousel"
+ rule.setContent {
+ Carousel(slideCount = 0, modifier = Modifier.testTag(testTag)) {}
+ }
+
+ rule.onNodeWithTag(testTag).assertExists()
+ }
+}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material/pager/PagerTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material/pager/PagerTest.kt
new file mode 100644
index 0000000..23df054
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material/pager/PagerTest.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 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.material.pager
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.tv.material.ExperimentalTvMaterialApi
+import org.junit.Rule
+import org.junit.Test
+
+class PagerTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @OptIn(ExperimentalTvMaterialApi::class)
+ @Test
+ fun pager_zeroSlideCount_drawsSomething() {
+ val testTag = "pager"
+ rule.setContent {
+ Pager(slideCount = 0, modifier = Modifier.testTag(testTag)) {}
+ }
+
+ rule.onNodeWithTag(testTag).assertExists()
+ }
+}
\ No newline at end of file
diff --git a/tv/tv-material/src/main/java/androidx/tv/material/ExperimentalTvMaterialApi.kt b/tv/tv-material/src/main/java/androidx/tv/material/ExperimentalTvMaterialApi.kt
new file mode 100644
index 0000000..b0e19e2
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material/ExperimentalTvMaterialApi.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 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.material
+
+@RequiresOptIn(
+ "This tv-material API is experimental and likely to change or be removed in the future."
+)
+annotation class ExperimentalTvMaterialApi
\ No newline at end of file
diff --git a/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt b/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt
new file mode 100644
index 0000000..ebd71f6
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material/carousel/Carousel.kt
@@ -0,0 +1,291 @@
+/*
+ * Copyright 2022 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.material.carousel
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+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.FocusState
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.unit.dp
+import androidx.tv.material.ExperimentalTvMaterialApi
+import androidx.tv.material.pager.Pager
+import java.lang.Math.floorMod
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.yield
+
+/**
+ * Composes a hero card rotator to highlight a piece of content.
+ *
+ * @param slideCount total number of slides present in the carousel.
+ * @param carouselState state associated with this carousel.
+ * @param timeToDisplaySlideMillis duration for which slide should be visible before moving to
+ * the next slide.
+ * @param enterTransition transition used to bring a slide into view.
+ * @param exitTransition transition used to remove a slide from view.
+ * @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)
+@ExperimentalTvMaterialApi
+@Composable
+fun Carousel(
+ slideCount: Int,
+ modifier: Modifier = Modifier,
+ carouselState: CarouselState = remember { CarouselState() },
+ timeToDisplaySlideMillis: Long = CarouselDefaults.TimeToDisplaySlideMillis,
+ enterTransition: EnterTransition = CarouselDefaults.EnterTransition,
+ exitTransition: ExitTransition = CarouselDefaults.ExitTransition,
+ carouselIndicator:
+ @Composable BoxScope.() -> Unit = {
+ CarouselDefaults.Indicator(
+ modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
+ carouselState = carouselState,
+ slideCount = slideCount)
+ },
+ content: @Composable (index: Int) -> Unit
+) {
+ CarouselStateUpdater(carouselState, slideCount)
+ var focusState: FocusState? by remember { mutableStateOf(null) }
+ val focusManager = LocalFocusManager.current
+
+ AutoScrollSideEffect(
+ timeToDisplaySlideMillis,
+ slideCount,
+ carouselState,
+ focusState)
+ Box(modifier = modifier
+ .onFocusChanged {
+ focusState = it
+ if (it.isFocused) {
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
+ }
+ .focusable()) {
+ Pager(
+ enterTransition = enterTransition,
+ exitTransition = exitTransition,
+ currentSlide = carouselState.slideIndex,
+ slideCount = slideCount
+ ) { content.invoke(it) }
+
+ this.carouselIndicator()
+ }
+}
+
+@OptIn(ExperimentalTvMaterialApi::class)
+@Composable
+private fun AutoScrollSideEffect(
+ timeToDisplaySlideMillis: Long,
+ slideCount: Int,
+ carouselState: CarouselState,
+ focusState: FocusState?
+) {
+ val currentTimeToDisplaySlideMillis by rememberUpdatedState(timeToDisplaySlideMillis)
+ val currentSlideCount by rememberUpdatedState(slideCount)
+ val carouselIsFocused = focusState?.isFocused ?: false
+ val carouselHasFocus = focusState?.hasFocus ?: false
+
+ if (!(carouselIsFocused || carouselHasFocus)) {
+ LaunchedEffect(carouselState) {
+ while (true) {
+ yield()
+ delay(currentTimeToDisplaySlideMillis)
+ if (carouselState.activePauseHandlesCount > 0) {
+ snapshotFlow { carouselState.activePauseHandlesCount }
+ .first { pauseHandleCount -> pauseHandleCount == 0 }
+ }
+ carouselState.nextSlide(currentSlideCount)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterialApi::class)
+@Composable
+private fun CarouselStateUpdater(carouselState: CarouselState, slideCount: Int) {
+ LaunchedEffect(carouselState, slideCount) {
+ if (slideCount != 0) {
+ carouselState.slideIndex = floorMod(carouselState.slideIndex, slideCount)
+ }
+ }
+}
+
+/**
+ * State of the Carousel which allows the user to specify the first slide that is shown when the
+ * Carousel is instantiated in the constructor.
+ *
+ * It also provides the user with support to pause and resume the auto-scroll behaviour of the
+ * Carousel.
+ * @param initialSlideIndex the index of the first slide that is displayed.
+ */
+@Stable
+@ExperimentalTvMaterialApi
+class CarouselState(initialSlideIndex: Int = 0) {
+ internal var activePauseHandlesCount by mutableStateOf(0)
+
+ /**
+ * The index of the slide that is currently displayed by the carousel
+ */
+ var slideIndex by mutableStateOf(initialSlideIndex)
+ internal set
+
+ /**
+ * Pauses the auto-scrolling behaviour of Carousel.
+ * The pause request is ignored if [slideIndex] is not the current slide that is visible.
+ * Returns a [ScrollPauseHandle] that can be used to resume
+ */
+ fun pauseAutoScroll(slideIndex: Int): ScrollPauseHandle {
+ if (this.slideIndex != slideIndex) {
+ return NoOpScrollPauseHandle
+ }
+ return ScrollPauseHandleImpl(this)
+ }
+
+ internal fun nextSlide(slideCount: Int) {
+ if (slideCount != 0) {
+ slideIndex = floorMod(slideIndex + 1, slideCount)
+ }
+ }
+}
+
+@ExperimentalTvMaterialApi
+/**
+ * Handle returned by [CarouselState.pauseAutoScroll] that can be used to resume auto-scroll.
+ */
+sealed interface ScrollPauseHandle {
+ /**
+ * Resumes the auto-scroll behaviour if there are no other active [ScrollPauseHandle]s.
+ */
+ fun resumeAutoScroll()
+}
+
+@OptIn(ExperimentalTvMaterialApi::class)
+internal object NoOpScrollPauseHandle : ScrollPauseHandle {
+ /**
+ * Resumes the auto-scroll behaviour if there are no other active [ScrollPauseHandle]s.
+ */
+ override fun resumeAutoScroll() {}
+}
+
+@OptIn(ExperimentalTvMaterialApi::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.
+ */
+ override fun resumeAutoScroll() {
+ if (active) {
+ active = false
+ carouselState.activePauseHandlesCount -= 1
+ }
+ }
+}
+
+@ExperimentalTvMaterialApi
+object CarouselDefaults {
+ /**
+ * Default time for which the slide is visible to the user.
+ */
+ val TimeToDisplaySlideMillis: Long = 5000
+
+ /**
+ * Default transition used to bring the slide into view
+ */
+ val EnterTransition: EnterTransition = fadeIn(animationSpec = tween(900))
+
+ /**
+ * Default transition used to remove the slide from view
+ */
+ val ExitTransition: ExitTransition = fadeOut(animationSpec = tween(900))
+
+ /**
+ * An indicator showing the position of the current slide among the slides of the carousel.
+ *
+ * @param carouselState is the state associated with the carousel of which this indicator is a
+ * part.
+ * @param slideCount total number of slides in the carousel
+ */
+ @ExperimentalTvMaterialApi
+ @Composable
+ fun Indicator(
+ carouselState: CarouselState,
+ slideCount: Int,
+ modifier: Modifier = Modifier
+ ) {
+ if (slideCount <= 0) {
+ Box(modifier = modifier)
+ } else {
+ val defaultSize = 8.dp
+ val inactiveColor = Color.LightGray
+ val activeColor = Color.White
+ val shape = CircleShape
+ val indicatorModifier = Modifier.size(defaultSize)
+
+ Box(modifier = modifier) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(defaultSize),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ repeat(slideCount) {
+ Box(indicatorModifier.background(
+ color =
+ if (it == carouselState.slideIndex) {
+ activeColor
+ } else {
+ inactiveColor
+ },
+ shape = shape
+ ))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material/carousel/CarouselItem.kt b/tv/tv-material/src/main/java/androidx/tv/material/carousel/CarouselItem.kt
new file mode 100644
index 0000000..0a58e20
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material/carousel/CarouselItem.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2022 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.material.carousel
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.FocusState
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.tv.material.ExperimentalTvMaterialApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+
+/**
+ * This composable is intended for use in Carousel.
+ * A composable that has
+ * - a [background] layer that is rendered as soon as the composable is visible.
+ * - an [overlay] layer that is rendered after a delay of
+ * [overlayEnterTransitionStartDelayMillis].
+ *
+ * @param overlayEnterTransitionStartDelayMillis time between the rendering of the
+ * background and the overlay.
+ * @param overlayEnterTransition animation used to bring the overlay into view.
+ * @param overlayExitTransition animation used to remove the overlay from view.
+ * @param background composable defining the background of the slide.
+ * @param overlay composable defining the content overlaid on the background.
+ */
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalComposeUiApi::class)
+@ExperimentalTvMaterialApi
+@Composable
+fun CarouselItem(
+ background: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ overlayEnterTransitionStartDelayMillis: Long =
+ CarouselItemDefaults.OverlayEnterTransitionStartDelayMillis,
+ overlayEnterTransition: EnterTransition = CarouselItemDefaults.OverlayEnterTransition,
+ overlayExitTransition: ExitTransition = CarouselItemDefaults.OverlayExitTransition,
+ overlay: @Composable () -> Unit
+) {
+ val overlayVisible = remember { MutableTransitionState(initialState = false) }
+ var focusState: FocusState? by remember { mutableStateOf(null) }
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(overlayVisible) {
+ snapshotFlow { overlayVisible.isIdle && overlayVisible.currentState }.first { it }
+ // slide has loaded completely.
+ if (focusState?.isFocused == true) {
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
+ }
+
+ Box(modifier = modifier
+ .onFocusChanged {
+ focusState = it
+ if (it.isFocused && overlayVisible.isIdle && overlayVisible.currentState) {
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
+ }.focusable()) {
+ background()
+
+ LaunchedEffect(overlayVisible) {
+ // After the delay, set overlay-visibility to true and trigger the animation to show the
+ // overlay.
+ delay(overlayEnterTransitionStartDelayMillis)
+ overlayVisible.targetState = true
+ }
+
+ AnimatedVisibility(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .onFocusChanged {
+ if (it.isFocused) { focusManager.moveFocus(FocusDirection.Enter) }
+ }
+ .focusable(),
+ visibleState = overlayVisible,
+ enter = overlayEnterTransition,
+ exit = overlayExitTransition
+ ) {
+ overlay.invoke()
+ }
+ }
+}
+
+@ExperimentalTvMaterialApi
+object CarouselItemDefaults {
+ /**
+ * Default delay between the background being rendered and the overlay being rendered.
+ */
+ val OverlayEnterTransitionStartDelayMillis: Long = 1500
+
+ /**
+ * Default transition to bring the overlay into view.
+ */
+ val OverlayEnterTransition: EnterTransition = slideInHorizontally(initialOffsetX = { it * 4 })
+
+ /**
+ * Default transition to remove overlay from view.
+ */
+ val OverlayExitTransition: ExitTransition = slideOutHorizontally()
+}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material/pager/Pager.kt b/tv/tv-material/src/main/java/androidx/tv/material/pager/Pager.kt
new file mode 100644
index 0000000..24f88ed
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material/pager/Pager.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2022 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.material.pager
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.with
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.tv.material.ExperimentalTvMaterialApi
+
+/**
+ * Composable that accepts a lambda that generates the slides based on the index provided and
+ * displays the slide associated with the index [currentSlide].
+ *
+ * @param modifier the modifier to apply to this component.
+ * @param enterTransition defines how the slide is animated into view.
+ * @param exitTransition defines how the slide is animated out of view.
+ * @param currentSlide the slide that is currently displayed by the pager.
+ * @param slideCount the total number of slides.
+ * @param content defines the slide composable for a given index.
+ */
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalAnimationApi::class)
+@ExperimentalTvMaterialApi
+@Composable
+internal fun Pager(
+ slideCount: Int,
+ modifier: Modifier = Modifier,
+ enterTransition: EnterTransition = PagerDefaults.EnterTransition,
+ exitTransition: ExitTransition = PagerDefaults.ExitTransition,
+ currentSlide: Int = 0,
+ content: @Composable (index: Int) -> Unit
+) {
+ if (slideCount <= 0) {
+ Box(modifier)
+ } else {
+ AnimatedContent(
+ modifier = modifier,
+ targetState = currentSlide.coerceIn(0, slideCount - 1),
+ transitionSpec = { enterTransition.with(exitTransition) }
+ ) {
+ content(it)
+ }
+ }
+}
+
+@ExperimentalTvMaterialApi
+private object PagerDefaults {
+ /**
+ * Default transition used to bring a slide into view
+ */
+ val EnterTransition: EnterTransition = fadeIn(animationSpec = tween(900))
+
+ /**
+ * Default transition used to remove a slide from view
+ */
+ val ExitTransition: ExitTransition = fadeOut(animationSpec = tween(900))
+}