diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts index 5ac77e8..e530b43 100644 --- a/apps/vbox/client/androidApp/build.gradle.kts +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -7,7 +7,7 @@ plugins { alias(libs.plugins.convention.ktor.client) } -val appVersion = "1.26.4" +val appVersion = "1.26.5" android { buildFeatures { buildConfig = true } diff --git a/libs/ui/src/commonMain/composeResources/drawable/menu.xml b/libs/ui/src/commonMain/composeResources/drawable/menu.xml new file mode 100644 index 0000000..be344b1 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/menu_open.xml b/libs/ui/src/commonMain/composeResources/drawable/menu_open.xml new file mode 100644 index 0000000..2ea4f1e --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/menu_open.xml @@ -0,0 +1,11 @@ + + + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt index d498d6b..41af505 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt @@ -1,52 +1,22 @@ package ru.shadowsparky.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.WideNavigationRail -import androidx.compose.material3.WideNavigationRailItem -import androidx.compose.material3.WideNavigationRailValue import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType -import androidx.compose.material3.rememberWideNavigationRailState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable -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.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.dp import ru.shadowsparky.ui.nav.Route @Immutable @@ -141,128 +111,3 @@ fun AppScaffold( } } } - -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -private fun DynamicNavigationScaffold( - modifier: Modifier, - navigationSuiteType: NavigationSuiteType, - navigationEntries: List>?, - currentRoute: T?, - onSelectNavigation: ((T) -> Unit)?, - scrollBehavior: TopAppBarScrollBehavior?, - appBar: @Composable (TopAppBarScrollBehavior?) -> Unit, - snackbarState: SnackbarHostState?, - content: @Composable (PaddingValues) -> Unit, -) { - val entries = navigationEntries.orEmpty() - val railState = rememberWideNavigationRailState(WideNavigationRailValue.Collapsed) - val isExpanded = railState.targetValue == WideNavigationRailValue.Expanded - var railHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(railHasFocus) { - if (railHasFocus) railState.expand() else railState.collapse() - } - NavigationSuiteScaffoldLayout( - navigationSuiteType = navigationSuiteType, - navigationSuite = { - WideNavigationRail( - state = railState, - modifier = Modifier.onFocusChanged { railHasFocus = it.hasFocus }.focusGroup() - ) { - entries.forEach { entry -> - WideNavigationRailItem( - railExpanded = isExpanded, - selected = currentRoute == entry.route, - onClick = { onSelectNavigation?.invoke(entry.route) }, - icon = { Icon(entry.painter, entry.title) }, - label = { - if (isExpanded) Text(entry.title) - } - ) - } - } - } - ) { - Scaffold( - modifier = if (scrollBehavior != null) { - modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - modifier - }, - topBar = { appBar(scrollBehavior) }, - snackbarHost = { - snackbarState?.let { SnackbarHost(it) } - }, - content = content - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomBarScaffold( - modifier: Modifier, - navigationEntries: List>?, - currentRoute: T?, - appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, - content: @Composable (padding: PaddingValues) -> Unit, - scrollBehavior: TopAppBarScrollBehavior?, - onSelectNavigation: ((T) -> Unit)?, - snackbarState: SnackbarHostState? -) { - Scaffold( - bottomBar = { BottomBar(navigationEntries, currentRoute, onSelectNavigation) }, - topBar = { appBar(scrollBehavior) }, - modifier = if (scrollBehavior != null) { - modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - modifier - }, snackbarHost = { - snackbarState?.let { SnackbarHost(it) } - }, - content = content - ) -} - -@Composable -private fun BottomBar( - navigationEntries: List>?, - currentRoute: T?, - onSelectNavigation: ((T) -> Unit)? -) { - navigationEntries?.let { entries -> - Box( - modifier = Modifier - .fillMaxWidth() - .background( - Brush.verticalGradient( - listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer) - ) - ) - .padding(8.dp) - .windowInsetsPadding(WindowInsets.navigationBars) - ) { - NavigationBar( - containerColor = navBarContentColor(), - modifier = Modifier.clip(RoundedCornerShape(24.dp)).blurEffect() - ) { - entries.forEach { entry -> - NavigationBarItem( - selected = currentRoute == entry.route, - onClick = { onSelectNavigation?.invoke(entry.route) }, - icon = { Icon(entry.painter, contentDescription = entry.title) } - ) - } - } - } - } -} - -@Composable -private fun navBarContentColor(): Color { - return if (isBlurEnabled()) { - MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) - } else { - NavigationBarDefaults.containerColor - } -} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BottomScaffold.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BottomScaffold.kt new file mode 100644 index 0000000..df62fa5 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BottomScaffold.kt @@ -0,0 +1,98 @@ +package ru.shadowsparky.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import ru.shadowsparky.ui.nav.Route + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BottomBarScaffold( + modifier: Modifier, + navigationEntries: List>?, + currentRoute: T?, + appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, + onSelectNavigation: ((T) -> Unit)?, + snackbarState: SnackbarHostState? +) { + Scaffold( + bottomBar = { BottomBar(navigationEntries, currentRoute, onSelectNavigation) }, + topBar = { appBar(scrollBehavior) }, + modifier = if (scrollBehavior != null) { + modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + modifier + }, snackbarHost = { + snackbarState?.let { SnackbarHost(it) } + }, + content = content + ) +} + +@Composable +private fun BottomBar( + navigationEntries: List>?, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)? +) { + navigationEntries?.let { entries -> + Box( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer) + ) + ) + .padding(8.dp) + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + NavigationBar( + containerColor = navBarContentColor(), + modifier = Modifier.clip(RoundedCornerShape(24.dp)).blurEffect() + ) { + entries.forEach { entry -> + NavigationBarItem( + selected = currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { Icon(entry.painter, contentDescription = entry.title) } + ) + } + } + } + } +} + +@Composable +private fun navBarContentColor(): Color { + return if (isBlurEnabled()) { + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + } else { + NavigationBarDefaults.containerColor + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/DynamicScaffold.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/DynamicScaffold.kt new file mode 100644 index 0000000..ec1c187 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/DynamicScaffold.kt @@ -0,0 +1,93 @@ +package ru.shadowsparky.ui + +import androiddev.libs.ui.generated.resources.Res +import androiddev.libs.ui.generated.resources.menu +import androiddev.libs.ui.generated.resources.menu_open +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.WideNavigationRail +import androidx.compose.material3.WideNavigationRailItem +import androidx.compose.material3.WideNavigationRailState +import androidx.compose.material3.WideNavigationRailValue +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.rememberWideNavigationRailState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +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.input.nestedscroll.nestedScroll +import org.jetbrains.compose.resources.painterResource +import ru.shadowsparky.ui.nav.Route + +internal val LocalWideNavigationRailState = compositionLocalOf { null } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun DynamicNavigationScaffold( + modifier: Modifier, + navigationSuiteType: NavigationSuiteType, + navigationEntries: List>?, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)?, + scrollBehavior: TopAppBarScrollBehavior?, + appBar: @Composable (TopAppBarScrollBehavior?) -> Unit, + snackbarState: SnackbarHostState?, + content: @Composable (PaddingValues) -> Unit, +) { + val entries = navigationEntries.orEmpty() + val railState = LocalWideNavigationRailState.current + ?: rememberWideNavigationRailState(WideNavigationRailValue.Collapsed) + var isExpanded by remember { + mutableStateOf(railState.targetValue == WideNavigationRailValue.Expanded) + } + LaunchedEffect(isExpanded) { + if (!isExpanded) railState.collapse() else railState.expand() + } + NavigationSuiteScaffoldLayout( + navigationSuiteType = navigationSuiteType, + navigationSuite = { + WideNavigationRail(state = railState) { + val resId = if (isExpanded) Res.drawable.menu_open else Res.drawable.menu + WideNavigationRailItem( + false, + onClick = { isExpanded = !isExpanded }, + icon = { Icon(painterResource(resId), null) }, + label = {}, + railExpanded = isExpanded + ) + entries.forEach { entry -> + WideNavigationRailItem( + railExpanded = isExpanded, + selected = currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { Icon(entry.painter, entry.title) }, + label = { if (isExpanded) Text(entry.title) } + ) + } + } + } + ) { + Scaffold( + modifier = if (scrollBehavior != null) { + modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + modifier + }, + topBar = { appBar(scrollBehavior) }, + snackbarHost = { snackbarState?.let { SnackbarHost(it) } }, + content = content + ) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt index a79ba01..a0ab650 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt @@ -104,21 +104,14 @@ fun ExpressiveLazyColumn( verticalArrangement = verticalArrangement ) { scope.sections.forEachIndexed { sectionIndex, section -> - if (sectionIndex != 0 && sectionSpacing > 0.dp) { item(key = "sectionSpacer_$sectionIndex") { - Spacer( - Modifier - .animateItem() - .height(sectionSpacing) - ) + Spacer(Modifier.animateItem().height(sectionSpacing)) } } - section.nodes.forEachIndexed { index, node -> val topRadius = if (index == 0) roundedMax else roundedMin val bottomRadius = if (index == section.nodes.lastIndex) roundedMax else roundedMin - item(key = node.key, contentType = node.contentType) { ExpressiveCard( topRadius = if (reverseLayout) bottomRadius else topRadius, diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt index a1f7152..54184ac 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt @@ -2,13 +2,17 @@ package ru.shadowsparky.ui.nav import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.WideNavigationRailValue +import androidx.compose.material3.rememberWideNavigationRailState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatableV2 import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import ru.shadowsparky.ui.LocalWideNavigationRailState import ru.shadowsparky.ui.theme.getColorScheme @OptIn(ExperimentalDecomposeApi::class, ExperimentalMaterial3ExpressiveApi::class) @@ -17,17 +21,20 @@ fun Nav( rootComponent: RootComponent<*>, process: @Composable (child: Child, route: Route) -> Unit ) { - MaterialExpressiveTheme(getColorScheme()) { - Children( - stack = rootComponent.valueChildStack, - animation = predictiveBackAnimation( - backHandler = rootComponent.backHandler, - fallbackAnimation = stackAnimation(fade()), - onBack = rootComponent::onBack, - selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, - ), - ) { - process(it.instance, it.configuration as Route) + val railState = rememberWideNavigationRailState(WideNavigationRailValue.Collapsed) + CompositionLocalProvider(LocalWideNavigationRailState provides railState) { + MaterialExpressiveTheme(getColorScheme()) { + Children( + stack = rootComponent.valueChildStack, + animation = predictiveBackAnimation( + backHandler = rootComponent.backHandler, + fallbackAnimation = stackAnimation(fade()), + onBack = rootComponent::onBack, + selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, + ), + ) { + process(it.instance, it.configuration as Route) + } } } }