feat: add scroll to top functionality across root screens

This commit is contained in:
tapframe 2026-05-16 21:59:07 +05:30
parent 3c61e0d39e
commit 59bfb3f26b
6 changed files with 119 additions and 29 deletions

View file

@ -181,6 +181,8 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@ -544,8 +546,11 @@ private fun MainAppContent(
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
var searchFocusRequestCount by remember { mutableStateOf(0) }
val homeScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val searchScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
@ -602,9 +607,28 @@ private fun MainAppContent(
.sorted()
}
LaunchedEffect(nativeRequestedTab) {
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
selectedTab = nativeRequestedTab.toAppScreenTab()
fun handleRootTabClick(tab: AppScreenTab) {
if (selectedTab != tab) {
selectedTab = tab
return
}
when (tab) {
AppScreenTab.Home -> homeScrollToTopRequests.tryEmit(Unit)
AppScreenTab.Search -> {
searchFocusRequestCount++
searchScrollToTopRequests.tryEmit(Unit)
}
AppScreenTab.Library -> libraryScrollToTopRequests.tryEmit(Unit)
AppScreenTab.Settings -> settingsRootActionRequests.tryEmit(Unit)
}
}
LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) {
NativeTabBridge.requestedTabs.collectLatest { requestedTab ->
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
handleRootTabClick(requestedTab.toAppScreenTab())
}
}
}
@ -1059,35 +1083,29 @@ private fun MainAppContent(
NuvioNavigationBar {
NavItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home },
onClick = { handleRootTabClick(AppScreenTab.Home) },
icon = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
)
NavItem(
selected = selectedTab == AppScreenTab.Search,
onClick = {
if (selectedTab == AppScreenTab.Search) {
searchFocusRequestCount++
} else {
selectedTab = AppScreenTab.Search
}
},
onClick = { handleRootTabClick(AppScreenTab.Search) },
icon = Res.drawable.sidebar_search,
contentDescription = stringResource(Res.string.compose_nav_search),
)
NavItem(
selected = selectedTab == AppScreenTab.Library,
onClick = { selectedTab = AppScreenTab.Library },
onClick = { handleRootTabClick(AppScreenTab.Library) },
icon = Res.drawable.sidebar_library,
contentDescription = stringResource(Res.string.compose_nav_library),
)
NavItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onClick = { handleRootTabClick(AppScreenTab.Settings) },
) {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onClick = { handleRootTabClick(AppScreenTab.Settings) },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
@ -1106,6 +1124,11 @@ private fun MainAppContent(
.padding(innerPadding),
selectedTab = selectedTab,
searchFocusRequestCount = searchFocusRequestCount,
rootActionsEnabled = tabsRouteActive,
homeScrollToTopRequests = homeScrollToTopRequests,
searchScrollToTopRequests = searchScrollToTopRequests,
libraryScrollToTopRequests = libraryScrollToTopRequests,
settingsRootActionRequests = settingsRootActionRequests,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
@ -1160,13 +1183,7 @@ private fun MainAppContent(
if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar(
selectedTab = selectedTab,
onTabSelected = { tab ->
if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) {
searchFocusRequestCount++
} else {
selectedTab = tab
}
},
onTabSelected = ::handleRootTabClick,
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
@ -2196,6 +2213,11 @@ private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
searchFocusRequestCount: Int = 0,
rootActionsEnabled: Boolean = true,
homeScrollToTopRequests: Flow<Unit>,
searchScrollToTopRequests: Flow<Unit>,
libraryScrollToTopRequests: Flow<Unit>,
settingsRootActionRequests: Flow<Unit>,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
@ -2228,6 +2250,7 @@ private fun AppTabHost(
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
scrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
@ -2244,12 +2267,14 @@ private fun AppTabHost(
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
)
}
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
)
@ -2258,6 +2283,8 @@ private fun AppTabHost(
AppScreenTab.Settings -> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = settingsRootActionRequests,
rootActionsEnabled = rootActionsEnabled,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,

View file

@ -1,8 +1,8 @@
package com.nuvio.app.core.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
internal enum class NativeNavigationTab {
Home,
@ -18,11 +18,11 @@ internal enum class NativeNavigationTab {
}
internal object NativeTabBridge {
private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home)
val requestedTab: StateFlow<NativeNavigationTab> = _requestedTab.asStateFlow()
private val _requestedTabs = MutableSharedFlow<NativeNavigationTab>(extraBufferCapacity = 1)
val requestedTabs: SharedFlow<NativeNavigationTab> = _requestedTabs.asSharedFlow()
fun requestTab(tabName: String) {
_requestedTab.value = NativeNavigationTab.fromName(tabName)
_requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName))
}
fun publishSelectedTab(tab: NativeNavigationTab) {

View file

@ -60,6 +60,8 @@ import com.nuvio.app.features.home.components.HomeCollectionRowSection
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.home.components.ContinueWatchingLayout
@ -72,6 +74,7 @@ import org.jetbrains.compose.resources.stringResource
fun HomeScreen(
modifier: Modifier = Modifier,
animateCollectionGifs: Boolean = true,
scrollToTopRequests: Flow<Unit> = emptyFlow(),
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -107,6 +110,12 @@ fun HomeScreen(
}.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(scrollToTopRequests) {
scrollToTopRequests.collect {
homeListState.animateScrollToItem(0)
}
}
LaunchedEffect(networkStatusUiState.condition) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -32,6 +33,8 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@ -46,6 +49,7 @@ private data class LibraryRemovalTarget(
@Composable
fun LibraryScreen(
modifier: Modifier = Modifier,
scrollToTopRequests: Flow<Unit> = emptyFlow(),
onPosterClick: ((LibraryItem) -> Unit)? = null,
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
) {
@ -57,6 +61,7 @@ fun LibraryScreen(
var pendingRemovalTarget by remember { mutableStateOf<LibraryRemovalTarget?>(null) }
var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
val retryLibraryLoad: () -> Unit = {
NetworkStatusRepository.requestRefresh(force = true)
@ -89,9 +94,16 @@ fun LibraryScreen(
}
}
LaunchedEffect(scrollToTopRequests) {
scrollToTopRequests.collect {
listState.animateScrollToItem(0)
}
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
listState = listState,
) {
stickyHeader {
androidx.compose.foundation.layout.Column(

View file

@ -57,7 +57,9 @@ import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWid
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.watched.WatchedRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import nuvio.composeapp.generated.resources.Res
@ -83,6 +85,7 @@ fun SearchScreen(
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
searchFocusRequestCount: Int = 0,
scrollToTopRequests: Flow<Unit> = emptyFlow(),
) {
val focusRequester = remember { FocusRequester() }
@ -115,6 +118,12 @@ fun SearchScreen(
}
}
LaunchedEffect(scrollToTopRequests) {
scrollToTopRequests.collect {
listState.animateScrollToItem(0)
}
}
val addonRefreshKey = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null

View file

@ -80,6 +80,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -90,6 +93,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
rootActionRequests: Flow<Unit> = emptyFlow(),
rootActionsEnabled: Boolean = true,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
onMetaScreenClick: () -> Unit = {},
@ -200,17 +205,31 @@ fun SettingsScreen(
}
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val scrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
val previousPage = page.previousPage()
LaunchedEffect(rootActionRequests, rootActionsEnabled, page) {
rootActionRequests.collect {
if (!rootActionsEnabled) return@collect
val pageToOpen = page.previousPage()
if (pageToOpen != null) {
currentPage = pageToOpen.name
} else {
scrollToTopRequests.tryEmit(Unit)
}
}
}
PlatformBackHandler(
enabled = previousPage != null,
enabled = rootActionsEnabled && previousPage != null,
onBack = { previousPage?.let { currentPage = it.name } },
)
if (maxWidth >= 768.dp) {
TabletSettingsScreen(
page = page,
scrollToTopRequests = scrollToTopRequests,
onPageChange = { currentPage = it.name },
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
@ -259,6 +278,7 @@ fun SettingsScreen(
} else {
MobileSettingsScreen(
page = page,
scrollToTopRequests = scrollToTopRequests,
onPageChange = { currentPage = it.name },
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
@ -317,6 +337,7 @@ fun SettingsScreen(
@Composable
private fun MobileSettingsScreen(
page: SettingsPage,
scrollToTopRequests: Flow<Unit>,
onPageChange: (SettingsPage) -> Unit,
showLoadingOverlay: Boolean,
holdToSpeedEnabled: Boolean,
@ -427,6 +448,12 @@ private fun MobileSettingsScreen(
}
}
LaunchedEffect(scrollToTopRequests) {
scrollToTopRequests.collect {
listState.animateScrollToItem(0)
}
}
NuvioScreen(
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
listState = listState,
@ -624,6 +651,7 @@ private fun rememberSettingsRootSearchRevealConnection(
@Composable
private fun TabletSettingsScreen(
page: SettingsPage,
scrollToTopRequests: Flow<Unit>,
onPageChange: (SettingsPage) -> Unit,
showLoadingOverlay: Boolean,
holdToSpeedEnabled: Boolean,
@ -773,6 +801,11 @@ private fun TabletSettingsScreen(
rootSearchRevealAnimating = false
}
}
LaunchedEffect(scrollToTopRequests) {
scrollToTopRequests.collect {
listState.animateScrollToItem(0)
}
}
LazyColumn(
state = listState,
modifier = Modifier