use experimental rail

This commit is contained in:
2026-06-22 18:27:27 +03:00
parent 7a254a5085
commit 420dc4e044
6 changed files with 150 additions and 157 deletions
@@ -39,3 +39,7 @@ fun createNavigationList(): List<NavigationEntry<Route>> {
)
return list
}
fun List<NavigationEntry<*>>.findTitle(route: Route): String? {
return firstOrNull { it.route == route }?.title
}
@@ -1,147 +1,96 @@
package ru.shadowsparky.vbox.shared.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.TextFieldValue
import ru.shadowsparky.ui.BaseScreen
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.USE_EXPERIMENTAL_RAIL
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.ui.currentWindowType
import ru.shadowsparky.ui.isCompact
import ru.shadowsparky.ui.isExpanded
import ru.shadowsparky.ui.rememberBlurState
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieContent
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
import ru.shadowsparky.vbox.shared.presentation.search.SearchVideoContent
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieScreen
import ru.shadowsparky.vbox.shared.presentation.search.SearchScreen
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsContent
@Composable
fun MainScreen(
route: Route,
child: Child,
rootComponent: RootComponent
) {
if (child is Child.SearchChild) {
SearchScreenInternal(child, route, rootComponent)
} else {
MainScreenInternal(route, child, rootComponent)
fun MainScreen(route: Route, child: Child, rootComponent: RootComponent) {
when (child) {
is Child.SearchChild -> SearchScreen(child.component, route) { rootComponent.nav(it, true) }
is Child.SavedMovieChild -> SavedMovieScreen(child.component, route) { rootComponent.navReplace(it) }
else -> MainScreenInternal(route, child) { rootComponent.navReplace(it) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchScreenInternal(
child: Child.SearchChild,
fun BaseMainScreen(
appBar: @Composable () -> Unit,
route: Route,
rootComponent: RootComponent,
scrollBehavior: TopAppBarScrollBehavior,
onNavigate: (Route) -> Unit,
content: @Composable (PaddingValues) -> Unit
) {
val component = child.component
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val requester by remember { mutableStateOf(FocusRequester()) }
val windowType = currentWindowType()
val searchAppBar: (@Composable () -> Unit) = {
SearchAppBar(
textFieldValue = textFieldValue,
onDone = { component.done(textFieldValue.text) },
onValueChange = { textFieldValue = it },
focusRequester = requester
)
}
val blurState = rememberBlurState()
CompositionLocalProvider(LocalBlurState provides blurState) {
BaseScreen(
appBar = {
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) searchAppBar()
},
appBar = { appBar() },
currentRoute = route,
onSelectNavigation = { rootComponent.nav(it, true) },
onSelectNavigation = { onNavigate(it) },
navigationEntries = createNavigationList(),
colorScheme = getColorScheme(),
scrollBehavior = scrollBehavior
) {
val content: @Composable () -> Unit = {
SearchVideoContent(
child.component,
it,
requester
) { textFieldValue = it }
}
if (windowType.isExpanded && USE_EXPERIMENTAL_RAIL) {
Column {
searchAppBar()
content()
}
} else {
content()
}
) { padding ->
content(padding)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreenInternal(route: Route, child: Child, rootComponent: RootComponent) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
private fun MainScreenInternal(route: Route, child: Child, onNavigate: (Route) -> Unit) {
val navList = createNavigationList()
val windowType = currentWindowType()
val blurState = rememberBlurState()
CompositionLocalProvider(LocalBlurState provides blurState) {
BaseScreen(
appBar = {
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) {
navList.firstOrNull { it.route == route }?.title?.let {
if (isTv()) {
TopAppBar(
title = { Text(it) },
scrollBehavior = scrollBehavior,
modifier = Modifier.applyBlurEffect(blurState),
colors = ComponentDefaults.topAppBarColors()
)
} else {
LargeTopAppBar(
title = { Text(it) },
scrollBehavior = scrollBehavior,
modifier = Modifier.applyBlurEffect(blurState),
colors = ComponentDefaults.topAppBarColors()
)
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
BaseMainScreen(
appBar = {
navList.findTitle(route)?.let { title ->
if (currentWindowType().isCompact) {
LargeTopAppBar(
title = { Text(title) },
scrollBehavior = scrollBehavior,
modifier = Modifier.applyBlurEffect(LocalBlurState.current),
colors = ComponentDefaults.topAppBarColors()
)
} else {
TopAppBar(
title = { Text(title) },
scrollBehavior = scrollBehavior,
modifier = Modifier.applyBlurEffect(LocalBlurState.current),
colors = ComponentDefaults.topAppBarColors()
)
}
},
currentRoute = route,
onSelectNavigation = { rootComponent.navReplace(it) },
navigationEntries = navList,
colorScheme = getColorScheme(),
scrollBehavior = scrollBehavior
) { padding ->
}
},
route = route,
scrollBehavior = scrollBehavior,
onNavigate = onNavigate,
content = { padding ->
when (child) {
is Child.VideosChild -> VideoContent(child.component, padding)
is Child.SettingsChild -> SettingsContent(child.component, padding)
is Child.SavedMovieChild -> SavedMovieContent(child.component, padding)
else -> {}
}
}
}
)
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
@@ -42,7 +41,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -119,7 +117,6 @@ fun VideoScreenContent(
emptyResource: StringResource,
onRefresh: () -> Unit,
onShowOverview: (Long) -> Unit,
extraFab: (@Composable BoxScope.() -> Unit)? = null,
onLoad: () -> Unit
) {
LaunchedEffect(Unit) {
@@ -130,7 +127,7 @@ fun VideoScreenContent(
onRefresh()
}
} else if (items != null) {
VideosInfo(items = items, paddingValues, emptyResource, extraFab) {
VideosInfo(items = items, paddingValues, emptyResource) {
onShowOverview(it.id)
}
} else {
@@ -143,16 +140,11 @@ fun VideosInfo(
items: List<VideoItem>,
paddingValues: PaddingValues,
emptyResource: StringResource,
extraFab: (@Composable BoxScope.() -> Unit)? = null,
onClick: (item: VideoItem) -> Unit
) {
val blur = LocalBlurState.current
val gridState = rememberLazyGridState()
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
var fabHeight by remember { mutableStateOf(0.dp) }
Box(modifier = Modifier.fillMaxSize()) {
if (items.isNotEmpty()) {
LazyVerticalGrid(
@@ -164,7 +156,7 @@ fun VideosInfo(
start = paddingValues.calculateStartPadding(layoutDirection),
top = paddingValues.calculateTopPadding(),
end = paddingValues.calculateEndPadding(layoutDirection),
bottom = paddingValues.calculateBottomPadding() + (fabHeight / 2)
bottom = paddingValues.calculateBottomPadding()
),
state = gridState
) {
@@ -179,19 +171,6 @@ fun VideosInfo(
} else {
VideosNotFound(emptyResource)
}
val rootBoxScope = this
extraFab?.let {
Box(
modifier = Modifier.align(Alignment.BottomEnd)
.onGloballyPositioned { coordinates ->
fabHeight = with(density) {
coordinates.size.height.toDp()
}
}
) { it(rootBoxScope) }
}
}
}
@@ -9,18 +9,19 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,19 +29,24 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.presentation.BaseMainScreen
import ru.shadowsparky.vbox.shared.presentation.createNavigationList
import ru.shadowsparky.vbox.shared.presentation.findTitle
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreenContent
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingValues) {
val items by comp.all.collectAsStateWithLifecycle(null)
fun SavedMovieScreen(comp: SavedMovieScreenComponent, route: Route, onNavigate: (Route) -> Unit) {
val tags by comp.userTags.collectAsStateWithLifecycle(null)
val navList = createNavigationList()
var filterBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
if (filterBottomSheetVisible) {
FilterBottomSheet(
@@ -50,6 +56,33 @@ fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingVal
onFilterBy = { comp.filterByTag(it) }
)
}
BaseMainScreen(
appBar = {
TopAppBar(
title = { navList.findTitle(route)?.let { Text(it) } },
actions = {
IconButton(
onClick = { filterBottomSheetVisible = true }
) {
Icon(
painterResource(Res.drawable.filter_list),
contentDescription = null
)
}
},
colors = ComponentDefaults.topAppBarColors()
)
},
route = route,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
onNavigate = onNavigate,
content = { SavedMovieContent(comp, it) }
)
}
@Composable
private fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingValues) {
val items by comp.all.collectAsStateWithLifecycle(null)
VideoScreenContent(
paddingValues,
null,
@@ -57,23 +90,7 @@ fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingVal
onRefresh = {},
onShowOverview = { comp.showOverview(it) },
emptyResource = Res.string.saved_movies_not_found,
onLoad = {},
extraFab = {
if (tags?.isNotEmpty() == true) {
FloatingActionButton(
onClick = { filterBottomSheetVisible = true },
modifier = Modifier.padding(
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current) + 4.dp,
bottom = paddingValues.calculateBottomPadding() + 4.dp
)
) {
Icon(
painterResource(Res.drawable.filter_list),
contentDescription = null
)
}
}
}
onLoad = {}
)
}
@@ -35,13 +35,17 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -62,7 +66,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.LocalBlurState
@@ -71,7 +74,42 @@ import ru.shadowsparky.ui.applyBlurSource
import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.components.ExpressiveLazyColumn
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.BaseMainScreen
import ru.shadowsparky.vbox.shared.presentation.imePaddingInternal
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
component: SearchScreenComponent,
route: Route,
onNavigate: (Route) -> Unit
) {
val requester by remember { mutableStateOf(FocusRequester()) }
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
BaseMainScreen(
appBar = {
SearchAppBar(
textFieldValue = textFieldValue,
onDone = { component.done(textFieldValue.text) },
onValueChange = { textFieldValue = it },
focusRequester = requester
)
},
content = {
SearchVideoContent(
component,
it,
requester
) { textFieldValue = it }
},
route = route,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
onNavigate = onNavigate
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -103,7 +141,7 @@ fun SearchVideoContent(
Text(
text = stringResource(Res.string.search_empty_now),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { },
modifier = Modifier.focusRequester(focusRequester).clickable {},
textAlign = TextAlign.Center
)
}
@@ -3,12 +3,12 @@ package ru.shadowsparky.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -29,12 +29,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import ru.shadowsparky.ui.nav.Route
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.ui.theme.getColorScheme
const val USE_EXPERIMENTAL_RAIL = false
const val USE_EXPERIMENTAL_RAIL = true
@Immutable
data class NavigationEntry<out T : Route>(
@@ -125,9 +126,7 @@ private fun <T : Route> BottomBar(
NavigationBarItem(
currentRoute == entry.route,
onClick = { onSelectNavigation?.invoke(entry.route) },
icon = {
Icon(entry.painter, contentDescription = entry.title)
},
icon = { Icon(entry.painter, contentDescription = entry.title) },
label = { Text(entry.title) }
)
}
@@ -144,23 +143,30 @@ private fun <T : Route> ExpandedContent(
onSelectNavigation: ((T) -> Unit)?,
content: @Composable ((PaddingValues) -> Unit)
) {
Row(Modifier.fillMaxSize().padding(values)) {
Spacer(Modifier.width(4.dp))
NavigationRail {
val topPadding = values.calculateTopPadding()
val bottomPadding = values.calculateBottomPadding()
val startPadding = values.calculateStartPadding(LayoutDirection.Ltr)
val endPadding = values.calculateEndPadding(LayoutDirection.Ltr)
Row(modifier = Modifier.fillMaxSize().padding(top = topPadding)) {
NavigationRail(modifier = Modifier.padding(horizontal = 4.dp)) {
navigationEntries.forEach { entry ->
NavigationRailItem(
selected = currentRoute == entry.route,
onClick = { onSelectNavigation?.invoke(entry.route) },
icon = {
Icon(entry.painter, contentDescription = entry.title)
},
icon = { Icon(entry.painter, contentDescription = entry.title) },
label = { Text(entry.title) }
)
}
}
Spacer(Modifier.width(4.dp))
Box(Modifier.weight(1f)) {
content(PaddingValues(0.dp))
Box(modifier = Modifier.weight(1f)) {
content(
PaddingValues(
start = startPadding,
top = 0.dp,
end = endPadding,
bottom = bottomPadding
)
)
}
}
}