diff --git a/.idea/detekt.xml b/.idea/detekt.xml new file mode 100644 index 000000000..36b17e5cc --- /dev/null +++ b/.idea/detekt.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8e907001..fa1b83288 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist- androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" } +androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts index 666a446b7..73d5f31f8 100644 --- a/pillarbox-demo-shared/build.gradle.kts +++ b/pillarbox-demo-shared/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation.common) + implementation(libs.androidx.navigation.runtime) implementation(libs.androidx.paging.common) implementation(libs.srg.dataprovider.paging) implementation(libs.srg.dataprovider.retrofit) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt index a43bbb876..7f3867299 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt @@ -7,11 +7,12 @@ package ch.srgssr.pillarbox.demo.shared.ui import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.ViewList import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination import ch.srgssr.pillarbox.demo.shared.R /** @@ -41,13 +42,29 @@ sealed class HomeDestination( */ data object Lists : HomeDestination(NavigationRoutes.homeLists, R.string.lists, Icons.Default.ViewList) - /** - * Info home page - */ - data object Info : HomeDestination(NavigationRoutes.homeInformation, R.string.info, Icons.Default.Info) - /** * Info home page */ data object Search : HomeDestination(NavigationRoutes.searchHome, R.string.search, Icons.Default.Search) } + +/** + * Navigate as a top level destination. + * + * @param destination The [HomeDestination] to navigate to. + */ +fun NavController.navigate(destination: HomeDestination) { + navigate(destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt index 48cbe9ea7..b26d34fd2 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt @@ -12,7 +12,6 @@ object NavigationRoutes { const val homeSamples = "home_samples" const val homeSample = "home_sample" const val homeShowcases = "home_showcases" - const val homeInformation = "home_information" const val showcaseList = "showcase_list" const val story = "story" const val simplePlayer = "simple_player" diff --git a/pillarbox-demo-shared/src/main/res/values/strings.xml b/pillarbox-demo-shared/src/main/res/values/strings.xml index a474a24a3..2bc72db8f 100644 --- a/pillarbox-demo-shared/src/main/res/values/strings.xml +++ b/pillarbox-demo-shared/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ --> Examples - Information Lists Search Showcases diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt index 6550855f7..d2fac2a28 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt @@ -7,23 +7,27 @@ package ch.srgssr.pillarbox.demo.tv import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.CompositionLocalProvider +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination +import ch.srgssr.pillarbox.demo.shared.ui.navigate import ch.srgssr.pillarbox.demo.tv.ui.TVDemoNavigation import ch.srgssr.pillarbox.demo.tv.ui.TVDemoTopBar import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme @@ -50,36 +54,35 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface ) { - val destinations = listOf(HomeDestination.Examples, HomeDestination.Lists) + val destinations = listOf(HomeDestination.Examples, HomeDestination.Lists, HomeDestination.Search) val navController = rememberNavController() - val startDestination by remember(destinations) { mutableStateOf(destinations[0]) } + val focusRequester = remember { FocusRequester() } + val startDestination by remember(destinations) { mutableStateOf(destinations[0]) } var selectedDestination by remember { mutableStateOf(startDestination) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() - navController.addOnDestinationChangedListener { _, destination, _ -> - destinations.find { it.route == destination.route } - ?.takeIf { it != selectedDestination } - ?.let { selectedDestination = it } - } - - AnimatedVisibility(visible = selectedDestination != HomeDestination.Search) { - TVDemoTopBar( - destinations = destinations, - selectedDestination = selectedDestination, - modifier = Modifier.padding(vertical = MaterialTheme.paddings.baseline), - onDestinationClick = { destination -> - selectedDestination = destination - - navController.navigate(destination.route) - } - ) - } + TVDemoTopBar( + destinations = destinations, + currentNavDestination = currentBackStackEntry?.destination, + modifier = Modifier + .padding(vertical = MaterialTheme.paddings.baseline) + .focusRequester(focusRequester), + onDestinationClick = { destination -> + selectedDestination = destination + navController.navigate(destination) + } + ) TVDemoNavigation( navController = navController, startDestination = startDestination, modifier = Modifier.fillMaxSize() ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt index 53b2f8677..b12d5e176 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -38,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -46,6 +49,7 @@ import androidx.navigation.navArgument import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.itemsIndexed +import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState import androidx.tv.material3.Card import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme @@ -55,6 +59,7 @@ import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import kotlinx.coroutines.launch /** * Examples home @@ -79,8 +84,9 @@ fun ExamplesHome( ) { composable(NavigationRoutes.homeSamples) { ExamplesSection( - modifier = modifier, items = playlists, + focusFirstItem = false, + navController = navController, onItemClick = { index, _ -> navController.navigate("${NavigationRoutes.homeSample}/$index") } @@ -114,6 +120,8 @@ fun ExamplesHome( ExamplesSection( title = playlist.title, items = playlist.items, + focusFirstItem = true, + navController = navController, onItemClick = { _, item -> onItemSelected(item) } @@ -154,10 +162,14 @@ private fun ExamplesSection( modifier: Modifier = Modifier, title: String? = null, items: List, + focusFirstItem: Boolean, + navController: NavHostController, onItemClick: (index: Int, item: T) -> Unit, content: @Composable (item: T) -> Unit ) { - var focusedIndex by remember(items) { mutableIntStateOf(0) } + var focusedIndex by rememberSaveable(items, focusFirstItem) { + mutableIntStateOf(if (focusFirstItem) 0 else -1) + } val columnCount = 4 val focusManager = LocalFocusManager.current @@ -166,15 +178,7 @@ private fun ExamplesSection( } Column( - modifier = modifier - .onPreviewKeyEvent { - if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) { - focusedIndex = -1 - focusManager.moveFocus(FocusDirection.Up) - } else { - false - } - }, + modifier = modifier, verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) ) { if (title != null) { @@ -184,9 +188,38 @@ private fun ExamplesSection( ) } + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberTvLazyGridState() + TvLazyVerticalGrid( columns = TvGridCells.Fixed(columnCount), - modifier = Modifier.focusRestorer(), + modifier = Modifier + .focusRestorer() + .onPreviewKeyEvent { + if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) { + focusedIndex = -1 + focusManager.moveFocus(FocusDirection.Up) + } else if (it.key == Key.Back && it.type == KeyEventType.KeyDown) { + if (!isOnFirstRow) { + focusedIndex = 0 + + coroutineScope.launch { + scrollState.animateScrollToItem(focusedIndex) + } + + true + } else if (navController.previousBackStackEntry == null) { + focusedIndex = -1 + focusManager.moveFocus(FocusDirection.Up) + true + } else { + false + } + } else { + false + } + }, + state = scrollState, contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline), verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) @@ -195,10 +228,7 @@ private fun ExamplesSection( val focusRequester = remember { FocusRequester() } Card( - onClick = { - focusedIndex = index - onItemClick(index, item) - }, + onClick = { onItemClick(index, item) }, modifier = Modifier .height(104.dp) .focusRequester(focusRequester) diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt index ba4b6e356..28368965f 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt @@ -4,37 +4,40 @@ */ package ch.srgssr.pillarbox.demo.tv.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.ListItem -import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Tab +import androidx.tv.material3.TabRow import androidx.tv.material3.Text import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import ch.srgssr.pillarbox.ui.extension.handleDPadKeyEvents /** * Top bar displayed in the demo app on TV. * * @param destinations The list of destinations to display. - * @param selectedDestination The currently selected destination. + * @param currentNavDestination The currently destination selected. * @param modifier The [Modifier] to apply to the top bar. * @param onDestinationClick The action to perform the selected a destination. */ @@ -42,42 +45,64 @@ import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings @OptIn(ExperimentalComposeUiApi::class, ExperimentalTvMaterial3Api::class) fun TVDemoTopBar( destinations: List, - selectedDestination: HomeDestination, + currentNavDestination: NavDestination?, modifier: Modifier = Modifier, onDestinationClick: (destination: HomeDestination) -> Unit ) { - Row( + val focusManager = LocalFocusManager.current + val focusedTabIndex by rememberSaveable(currentNavDestination) { + val destinationHierarchy = currentNavDestination?.hierarchy.orEmpty() + + mutableIntStateOf( + destinations.indexOfFirst { dest -> + destinationHierarchy.any { it.route == dest.route } + } + ) + } + + TabRow( + selectedTabIndex = focusedTabIndex, modifier = modifier - .fillMaxWidth() - .focusRestorer(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), - verticalAlignment = Alignment.CenterVertically - ) { - destinations.forEach { destination -> - ListItem( - selected = destination == selectedDestination, - onClick = { onDestinationClick(destination) }, - modifier = Modifier.width(IntrinsicSize.Max), - headlineContent = { - Text(text = stringResource(destination.labelResId)) + .focusRestorer() + .handleDPadKeyEvents( + onRight = { + if (focusedTabIndex < destinations.lastIndex) { + focusManager.moveFocus(FocusDirection.Right) + } } ) - } - - Spacer(modifier = Modifier.weight(1f)) - - ListItem( - selected = selectedDestination == HomeDestination.Search, - onClick = { onDestinationClick(HomeDestination.Search) }, - modifier = Modifier.width(IntrinsicSize.Max), - shape = ListItemDefaults.shape(CircleShape), - headlineContent = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(HomeDestination.Search.labelResId) - ) + ) { + destinations.forEachIndexed { index, destination -> + key(index) { + val focusRequester = remember { + FocusRequester() + } + LaunchedEffect(focusedTabIndex) { + if (index == focusedTabIndex) { + focusRequester.requestFocus() + } + } + Tab( + modifier = Modifier.focusRequester(focusRequester), + selected = focusedTabIndex == index, + onFocus = { + if (index != focusedTabIndex) { + onDestinationClick(destination) + } + }, + onClick = { focusManager.moveFocus(FocusDirection.Down) } + ) { + Text( + text = stringResource(destination.labelResId), + modifier = Modifier.padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + style = MaterialTheme.typography.titleMedium + ) + } } - ) + } } } @@ -86,8 +111,8 @@ fun TVDemoTopBar( private fun TVDemoTopBarPreview() { PillarboxTheme { TVDemoTopBar( - destinations = listOf(HomeDestination.Examples, HomeDestination.Lists), - selectedDestination = HomeDestination.Examples, + destinations = listOf(HomeDestination.Examples, HomeDestination.Lists, HomeDestination.Search), + currentNavDestination = null, onDestinationClick = {} ) } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt index 2c4546600..d63f6e2a9 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -49,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -62,6 +65,7 @@ import androidx.paging.compose.itemKey import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.itemsIndexed +import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState import androidx.tv.material3.Card import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon @@ -92,6 +96,7 @@ import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings import coil.compose.AsyncImage import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import java.util.Date import kotlin.time.Duration.Companion.seconds import ch.srgssr.pillarbox.demo.shared.R as sharedR @@ -120,7 +125,9 @@ fun ListsHome( composable(NavigationRoutes.contentLists) { ListsSection( items = sections, + focusFirstItem = false, itemToString = { it.title }, + navController = navController, onItemClick = { index, _ -> navController.navigate("${NavigationRoutes.contentList}/$index") } @@ -139,9 +146,11 @@ fun ListsHome( ListsSection( title = section.title, items = section.contentList, + focusFirstItem = true, itemToString = { item -> item.destinationTitle }, + navController = navController, onItemClick = { _, contentList -> navController.navigate(contentList.destinationRoute) } @@ -234,10 +243,14 @@ private fun ListsSection( modifier: Modifier = Modifier, title: String? = null, items: List, + focusFirstItem: Boolean, + navController: NavHostController, itemToString: (item: T) -> String, onItemClick: (index: Int, item: T) -> Unit ) { - var focusedIndex by remember(items) { mutableIntStateOf(0) } + var focusedIndex by rememberSaveable(items, focusFirstItem) { + mutableIntStateOf(if (focusFirstItem) 0 else -1) + } val columnCount = 4 val focusManager = LocalFocusManager.current @@ -246,16 +259,7 @@ private fun ListsSection( } Column( - modifier = modifier - .padding(horizontal = MaterialTheme.paddings.baseline) - .onPreviewKeyEvent { - if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) { - focusedIndex = -1 - focusManager.moveFocus(FocusDirection.Up) - } else { - false - } - }, + modifier = modifier, verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) ) { if (title != null) { @@ -265,9 +269,41 @@ private fun ListsSection( ) } + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberTvLazyGridState() + TvLazyVerticalGrid( columns = TvGridCells.Fixed(columnCount), - modifier = Modifier.focusRestorer(), + modifier = Modifier + .focusRestorer() + .onKeyEvent( + onUpPress = { + if (isOnFirstRow) { + focusedIndex = -1 + focusManager.moveFocus(FocusDirection.Up) + } else { + false + } + }, + onBackPress = { + if (!isOnFirstRow) { + focusedIndex = 0 + + coroutineScope.launch { + scrollState.animateScrollToItem(focusedIndex) + } + + true + } else if (navController.previousBackStackEntry == null) { + focusedIndex = -1 + focusManager.moveFocus(FocusDirection.Up) + true + } else { + false + } + } + ), + state = scrollState, contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline), verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) @@ -276,10 +312,7 @@ private fun ListsSection( val focusRequester = remember { FocusRequester() } Card( - onClick = { - focusedIndex = index - onItemClick(index, item) - }, + onClick = { onItemClick(index, item) }, modifier = Modifier .height(104.dp) .focusRequester(focusRequester) @@ -391,7 +424,7 @@ private fun ListsSectionContent( if (items.itemCount == 0) { emptyScreen(modifier) } else { - var focusedIndex by remember(items, focusFirstItem) { + var focusedIndex by rememberSaveable(items, focusFirstItem) { mutableIntStateOf(if (focusFirstItem) 0 else -1) } @@ -403,18 +436,37 @@ private fun ListsSectionContent( } val itemHeight = if (hasMedia) 160.dp else 104.dp + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberTvLazyGridState() + TvLazyVerticalGrid( columns = TvGridCells.Fixed(columnCount), modifier = modifier .focusRestorer() - .onPreviewKeyEvent { - if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) { - focusedIndex = -1 - focusManager.moveFocus(FocusDirection.Up) - } else { - false + .onKeyEvent( + onUpPress = { + if (isOnFirstRow) { + focusedIndex = -1 + focusManager.moveFocus(FocusDirection.Up) + } else { + false + } + }, + onBackPress = { + if (!isOnFirstRow) { + focusedIndex = 0 + + coroutineScope.launch { + scrollState.animateScrollToItem(focusedIndex) + } + + true + } else { + false + } } - }, + ), + state = scrollState, contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline), verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) @@ -428,10 +480,7 @@ private fun ListsSectionContent( var containerWidth by remember { mutableIntStateOf(0) } Card( - onClick = { - focusedIndex = index - onItemClick(item) - }, + onClick = { onItemClick(item) }, modifier = Modifier .height(itemHeight) .focusRequester(focusRequester) @@ -633,6 +682,23 @@ private fun ListsSectionError( } } +private fun Modifier.onKeyEvent( + onUpPress: () -> Boolean, + onBackPress: () -> Boolean +): Modifier { + return this then Modifier.onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.key) { + Key.DirectionUp -> onUpPress() + Key.Back -> onBackPress() + else -> false + } + } else { + false + } + } +} + @Preview @Composable private fun ContentListsViewPreview() { diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt index d6769a7c7..867125645 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt @@ -19,18 +19,21 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview @@ -119,13 +122,24 @@ private fun SearchRow( onQueryChange: (query: String) -> Unit, onBuChange: (bu: Bu) -> Unit ) { + val focusManager = LocalFocusManager.current + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) ) { SearchInput( query = query, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { + if (it.key == Key.Back && it.type == KeyEventType.KeyDown) { + focusManager.moveFocus(FocusDirection.Up) + true + } else { + false + } + }, onQueryChange = onQueryChange ) @@ -151,17 +165,13 @@ private fun SearchInput( modifier: Modifier = Modifier, onQueryChange: (query: String) -> Unit ) { - val focusRequest = remember { FocusRequester() } - BasicTextField( value = query, onValueChange = onQueryChange, - modifier = modifier - .focusRequester(focusRequest) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small - ), + modifier = modifier.background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ), textStyle = MaterialTheme.typography.titleSmall .copy(color = MaterialTheme.colorScheme.onSurface), keyboardOptions = KeyboardOptions( @@ -186,10 +196,6 @@ private fun SearchInput( } } ) - - LaunchedEffect(Unit) { - focusRequest.requestFocus() - } } @Composable diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt deleted file mode 100644 index f21a0608d..000000000 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.demo.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import ch.srgssr.pillarbox.demo.BuildConfig -import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme -import ch.srgssr.pillarbox.demo.ui.theme.paddings - -/** - * Display current version name - */ -@Composable -fun InfoView() { - Box( - modifier = Modifier - .fillMaxSize() - .padding(MaterialTheme.paddings.small) - ) { - Text( - text = BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun PreviewInfoView() { - PillarboxTheme { - InfoView() - } -} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt index 14726769b..760f3a39c 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt @@ -44,7 +44,6 @@ import androidx.navigation.NavController import androidx.navigation.NavDeepLink import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -62,6 +61,7 @@ import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel +import ch.srgssr.pillarbox.demo.shared.ui.navigate import ch.srgssr.pillarbox.demo.trackPagView import ch.srgssr.pillarbox.demo.ui.examples.ExamplesHome import ch.srgssr.pillarbox.demo.ui.integrationLayer.SearchView @@ -80,7 +80,6 @@ private val topLevelRoutes = * Main view with all the navigation */ @OptIn(ExperimentalMaterial3Api::class) -@Suppress("StringLiteralDuplication") @Composable fun MainNavigation() { val navController = rememberNavController() @@ -140,10 +139,6 @@ fun MainNavigation() { listNavGraph(navController, ilRepository, ilHost) } - composable(HomeDestination.Info.route, DemoPageView("home", listOf("app", "pillarbox", "information"))) { - InfoView() - } - composable(route = NavigationRoutes.searchHome, DemoPageView("home", listOf("app", "pillarbox", "search"))) { val ilRepository = PlayerModule.createIlRepository(context) val viewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory(ilRepository)) @@ -232,19 +227,7 @@ private fun DemoBottomNavigation(navController: NavController, currentDestinatio label = { Text(stringResource(screen.labelResId)) }, selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, onClick = { - navController.navigate(screen.route) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } + navController.navigate(screen) } ) }