feat(ui): full adaptive layout prototype
This commit is contained in:
parent
fe46a32eba
commit
7535d8342e
9 changed files with 415 additions and 21 deletions
|
|
@ -3,6 +3,7 @@ package moe.lava.neon.ui
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MotionScheme
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
|
@ -21,12 +22,16 @@ import kotlinx.serialization.modules.polymorphic
|
||||||
import moe.lava.neon.ui.di.AppUiGraph
|
import moe.lava.neon.ui.di.AppUiGraph
|
||||||
import moe.lava.neon.ui.screens.Login
|
import moe.lava.neon.ui.screens.Login
|
||||||
import moe.lava.neon.ui.screens.Sample
|
import moe.lava.neon.ui.screens.Sample
|
||||||
|
import moe.lava.neon.ui.screens.chat.Chat
|
||||||
|
import moe.lava.neon.ui.screens.members.MembersList
|
||||||
import moe.lava.neon.ui.screens.navigator.Navigator
|
import moe.lava.neon.ui.screens.navigator.Navigator
|
||||||
import moe.lava.neon.ui.screens.navigator.NavigatorModel
|
import moe.lava.neon.ui.screens.navigator.NavigatorModel
|
||||||
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
|
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
|
||||||
|
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
|
||||||
|
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
private object Route {
|
object Route {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Login : NavKey
|
data object Login : NavKey
|
||||||
|
|
||||||
|
|
@ -35,6 +40,12 @@ private object Route {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Navigator(val left: Boolean) : NavKey
|
data class Navigator(val left: Boolean) : NavKey
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Chat : NavKey
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object MembersList : NavKey
|
||||||
}
|
}
|
||||||
|
|
||||||
private val config = SavedStateConfiguration {
|
private val config = SavedStateConfiguration {
|
||||||
|
|
@ -43,11 +54,13 @@ private val config = SavedStateConfiguration {
|
||||||
subclass(Route.Login::class, Route.Login.serializer())
|
subclass(Route.Login::class, Route.Login.serializer())
|
||||||
subclass(Route.Sample::class, Route.Sample.serializer())
|
subclass(Route.Sample::class, Route.Sample.serializer())
|
||||||
subclass(Route.Navigator::class, Route.Navigator.serializer())
|
subclass(Route.Navigator::class, Route.Navigator.serializer())
|
||||||
|
subclass(Route.Chat::class, Route.Chat.serializer())
|
||||||
|
subclass(Route.MembersList::class, Route.MembersList.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { t: Thread, e: Throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { t: Thread, e: Throwable ->
|
||||||
|
|
@ -64,7 +77,8 @@ fun App() {
|
||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
val init = if (graph.auth.token != null) Route.Sample else Route.Login
|
val init = if (graph.auth.token != null) Route.Sample else Route.Login
|
||||||
val backStack = rememberNavBackStack(config, init)
|
val backStack = rememberNavBackStack(config, Route.Sample)
|
||||||
|
val threePaneStrategy = rememberThreePaneSceneStrategy<NavKey>()
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
|
|
@ -72,6 +86,7 @@ fun App() {
|
||||||
rememberViewModelStoreNavEntryDecorator(),
|
rememberViewModelStoreNavEntryDecorator(),
|
||||||
),
|
),
|
||||||
onBack = { backStack.removeLastOrNull() },
|
onBack = { backStack.removeLastOrNull() },
|
||||||
|
sceneStrategy = threePaneStrategy,
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Route.Login> {
|
entry<Route.Login> {
|
||||||
Login(
|
Login(
|
||||||
|
|
@ -85,6 +100,8 @@ fun App() {
|
||||||
Sample(
|
Sample(
|
||||||
navTest = {
|
navTest = {
|
||||||
backStack.add(Route.Navigator(it))
|
backStack.add(Route.Navigator(it))
|
||||||
|
backStack.add(Route.Chat)
|
||||||
|
backStack.add(Route.MembersList)
|
||||||
},
|
},
|
||||||
onRequestLogout = {
|
onRequestLogout = {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
|
|
@ -92,7 +109,10 @@ fun App() {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<Route.Navigator> { key ->
|
|
||||||
|
entry<Route.Navigator>(
|
||||||
|
metadata = ThreePaneSceneStrategy.listPane()
|
||||||
|
) { key ->
|
||||||
if (key.left) {
|
if (key.left) {
|
||||||
Navigator(
|
Navigator(
|
||||||
NavigatorPreviewProvider.base2.copy(
|
NavigatorPreviewProvider.base2.copy(
|
||||||
|
|
@ -107,6 +127,20 @@ fun App() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry<Route.Chat>(
|
||||||
|
metadata = ThreePaneSceneStrategy.detailPane()
|
||||||
|
) {
|
||||||
|
Chat(
|
||||||
|
onOpenMembers = { backStack.add(Route.MembersList) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<Route.MembersList>(
|
||||||
|
metadata = ThreePaneSceneStrategy.extraPane()
|
||||||
|
) {
|
||||||
|
MembersList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt
Normal file
78
ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package moe.lava.neon.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation3.runtime.NavEntry
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import androidx.navigation3.scene.SceneStrategyScope
|
||||||
|
import moe.lava.neon.ui.screens.chat.Chat
|
||||||
|
import moe.lava.neon.ui.screens.members.MembersList
|
||||||
|
import moe.lava.neon.ui.screens.navigator.Navigator
|
||||||
|
import moe.lava.neon.ui.screens.navigator.NavigatorModel
|
||||||
|
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
|
||||||
|
import moe.lava.neon.ui.util.ThemedPreview
|
||||||
|
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
|
||||||
|
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
|
||||||
|
|
||||||
|
@PreviewScreenSizes
|
||||||
|
@Preview(
|
||||||
|
name = "Phone - Navigator",
|
||||||
|
device = "spec:width=411dp,height=892dp,orientation=portrait,dpi=420",
|
||||||
|
showSystemUi = true,
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun AppPrewiew() {
|
||||||
|
val chat: NavEntry<NavKey> = NavEntry(
|
||||||
|
Route.Chat,
|
||||||
|
metadata = ThreePaneSceneStrategy.detailPane(),
|
||||||
|
) {
|
||||||
|
Chat(onOpenMembers = {})
|
||||||
|
}
|
||||||
|
val nav: NavEntry<NavKey> = NavEntry(
|
||||||
|
Route.Navigator(true),
|
||||||
|
metadata = ThreePaneSceneStrategy.listPane(),
|
||||||
|
) {
|
||||||
|
Navigator(
|
||||||
|
NavigatorPreviewProvider.base2.copy(
|
||||||
|
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val nav2: NavEntry<NavKey> = NavEntry(
|
||||||
|
Route.Navigator(true),
|
||||||
|
metadata = ThreePaneSceneStrategy.listPane(),
|
||||||
|
) {
|
||||||
|
Navigator(
|
||||||
|
NavigatorPreviewProvider.base2.copy(
|
||||||
|
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val mem: NavEntry<NavKey> = NavEntry(
|
||||||
|
Route.MembersList,
|
||||||
|
metadata = ThreePaneSceneStrategy.extraPane(),
|
||||||
|
) {
|
||||||
|
MembersList()
|
||||||
|
}
|
||||||
|
val strat = rememberThreePaneSceneStrategy<NavKey>()
|
||||||
|
ThemedPreview {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
SceneStrategyScope<NavKey>().run {
|
||||||
|
strat.run {
|
||||||
|
calculateScene(listOf(chat, nav, mem))?.content()
|
||||||
|
?: if (LocalWindowInfo.current.containerDpSize.height == 892.dp) {
|
||||||
|
nav2.Content()
|
||||||
|
} else {
|
||||||
|
chat.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package moe.lava.neon.ui.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Chat(
|
||||||
|
onOpenMembers: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
onClick = { onOpenMembers() }
|
||||||
|
) {
|
||||||
|
Text("Hi! Click to open members")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package moe.lava.neon.ui.screens.members
|
||||||
|
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.plus
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import moe.lava.neon.ui.util.ThemedPreview
|
||||||
|
|
||||||
|
data class MembersListModel(
|
||||||
|
val members: List<Member>,
|
||||||
|
) {
|
||||||
|
data class Member(
|
||||||
|
val avatar: String,
|
||||||
|
val name: String,
|
||||||
|
val status: String?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sampleModel = MembersListModel(
|
||||||
|
listOf(
|
||||||
|
MembersListModel.Member("", "Mem1", "Something?"),
|
||||||
|
MembersListModel.Member("", "Mem2", null),
|
||||||
|
MembersListModel.Member("", "Mem4", "Bleh"),
|
||||||
|
MembersListModel.Member("", "Mem8", null),
|
||||||
|
MembersListModel.Member("", "Mem7", null),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MembersList(
|
||||||
|
model: MembersListModel = sampleModel,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
|
||||||
|
) { contentPadding ->
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.padding(contentPadding.plus(PaddingValues(16.dp)))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
||||||
|
) {
|
||||||
|
model.members.forEachIndexed { idx, member ->
|
||||||
|
MemberEntry(member, idx, model.members.size)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
model.members.forEachIndexed { idx, member ->
|
||||||
|
MemberEntry(member, idx, model.members.size)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
model.members.forEachIndexed { idx, member ->
|
||||||
|
MemberEntry(member, idx, model.members.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MemberEntry(
|
||||||
|
model: MembersListModel.Member,
|
||||||
|
index: Int,
|
||||||
|
size: Int,
|
||||||
|
) {
|
||||||
|
val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = {},
|
||||||
|
colors = colors,
|
||||||
|
leadingContent = { },
|
||||||
|
supportingContent = {
|
||||||
|
model.status?.let { Text(it) }
|
||||||
|
},
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(index, size)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MembersListPreview() {
|
||||||
|
ThemedPreview {
|
||||||
|
MembersList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,8 +42,13 @@ fun Channels(
|
||||||
model: NavigatorModel,
|
model: NavigatorModel,
|
||||||
bottomSpace: Dp,
|
bottomSpace: Dp,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
onNavigate: (NavigatorModel.Channel) -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
val colors = ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -87,7 +92,8 @@ fun Channels(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) {
|
Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) {
|
||||||
cat.channels.forEachIndexed { idx, ch ->
|
cat.channels.forEachIndexed { idx, ch ->
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = {},
|
selected = model.selected.second == ch,
|
||||||
|
onClick = { onNavigate(ch) },
|
||||||
colors = colors,
|
colors = colors,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
val res = when (ch.type) {
|
val res = when (ch.type) {
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ fun GuildButton(
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
val target = when {
|
val target = when {
|
||||||
isHovered -> 0.8f
|
|
||||||
isPressed -> 0.85f
|
isPressed -> 0.85f
|
||||||
selected -> 1f
|
selected -> 1f
|
||||||
|
isHovered -> 0.8f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
val fadingIn = isPressed && !selected || !isPressed && selected
|
val fadingIn = isPressed && !selected || !isPressed && selected
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -47,6 +47,7 @@ data class NavigatorModel(
|
||||||
val guildName: String,
|
val guildName: String,
|
||||||
val channels: List<Category>,
|
val channels: List<Category>,
|
||||||
val guilds: List<Guild>,
|
val guilds: List<Guild>,
|
||||||
|
val selected: Pair<Guild, Channel?>,
|
||||||
) {
|
) {
|
||||||
data class Category(
|
data class Category(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
@ -70,21 +71,38 @@ data class NavigatorModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Move channel's bottomSpace and padding into a Modifier, so this can be greatly simplified
|
||||||
|
// by sharing the Channels and GuildButton implementations in both layouts
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Navigator(
|
fun Navigator(
|
||||||
model: NavigatorModel
|
model: NavigatorModel
|
||||||
) {
|
) {
|
||||||
|
// TODO: this obviously should be done properly
|
||||||
|
var selected by remember { mutableStateOf(model.selected) }
|
||||||
|
val onNavigateChannel: (NavigatorModel.Channel) -> Unit = { ch -> selected = selected.copy(second = ch) }
|
||||||
|
val onNavigateGuild: (NavigatorModel.Guild) -> Unit = { guild -> selected = selected.copy(first = guild) }
|
||||||
|
|
||||||
when (model.guildNavPosition) {
|
when (model.guildNavPosition) {
|
||||||
NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(model)
|
NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(
|
||||||
NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator(model)
|
model.copy(selected = selected),
|
||||||
|
onNavigateGuild = onNavigateGuild,
|
||||||
|
onNavigateChannel = onNavigateChannel,
|
||||||
|
)
|
||||||
|
NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator(
|
||||||
|
model.copy(selected = selected),
|
||||||
|
onNavigateGuild = onNavigateGuild,
|
||||||
|
onNavigateChannel = onNavigateChannel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BottomSheetNavigator(
|
private fun BottomSheetNavigator(
|
||||||
model: NavigatorModel
|
model: NavigatorModel,
|
||||||
|
onNavigateGuild: (NavigatorModel.Guild) -> Unit,
|
||||||
|
onNavigateChannel: (NavigatorModel.Channel) -> Unit,
|
||||||
) {
|
) {
|
||||||
val morph = remember {
|
val morph = remember {
|
||||||
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
|
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
|
||||||
|
|
@ -97,7 +115,6 @@ private fun BottomSheetNavigator(
|
||||||
val offsetDp = with (LocalDensity.current) { offset.toDp() } - TopAppBarDefaults.TopAppBarExpandedHeight
|
val offsetDp = with (LocalDensity.current) { offset.toDp() } - TopAppBarDefaults.TopAppBarExpandedHeight
|
||||||
BottomSheetScaffold(
|
BottomSheetScaffold(
|
||||||
sheetContent = {
|
sheetContent = {
|
||||||
var selected by remember { mutableIntStateOf(-1) }
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -110,8 +127,8 @@ private fun BottomSheetNavigator(
|
||||||
GuildButton(
|
GuildButton(
|
||||||
model = guild,
|
model = guild,
|
||||||
morph = morph,
|
morph = morph,
|
||||||
selected = selected == idx,
|
selected = model.selected.first == guild,
|
||||||
onSelected = { selected = idx }
|
onSelected = { onNavigateGuild(guild) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,20 +142,26 @@ private fun BottomSheetNavigator(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Channels(model, offsetDp, innerPadding)
|
Channels(
|
||||||
|
model = model,
|
||||||
|
bottomSpace = offsetDp,
|
||||||
|
padding = innerPadding,
|
||||||
|
onNavigate = { onNavigateChannel(it) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SidebarNavigator(
|
private fun SidebarNavigator(
|
||||||
model: NavigatorModel
|
model: NavigatorModel,
|
||||||
|
onNavigateGuild: (NavigatorModel.Guild) -> Unit,
|
||||||
|
onNavigateChannel: (NavigatorModel.Channel) -> Unit,
|
||||||
) {
|
) {
|
||||||
val morph = remember {
|
val morph = remember {
|
||||||
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
|
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
|
||||||
}
|
}
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
var selected by remember { mutableIntStateOf(-1) }
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
|
@ -153,8 +176,8 @@ private fun SidebarNavigator(
|
||||||
GuildButton(
|
GuildButton(
|
||||||
model = guild,
|
model = guild,
|
||||||
morph = morph,
|
morph = morph,
|
||||||
selected = selected == idx,
|
selected = model.selected.first == guild,
|
||||||
onSelected = { selected = idx }
|
onSelected = { onNavigateGuild(guild) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +188,7 @@ private fun SidebarNavigator(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Channels(model, 0.dp, innerPadding)
|
Channels(model, 0.dp, innerPadding, onNavigate = { onNavigateChannel(it) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ internal class NavigatorPreviewProvider : PreviewParameterProvider<NavigatorMode
|
||||||
NavigatorModel.Guild(name = "Hutao", url = ""),
|
NavigatorModel.Guild(name = "Hutao", url = ""),
|
||||||
NavigatorModel.Guild(name = "huuutao", url = ""),
|
NavigatorModel.Guild(name = "huuutao", url = ""),
|
||||||
),
|
),
|
||||||
|
selected = NavigatorModel.Guild(name = "Hu Tao", url = "") to null,
|
||||||
channels = listOf(
|
channels = listOf(
|
||||||
NavigatorModel.Category(
|
NavigatorModel.Category(
|
||||||
name = "Text Channels",
|
name = "Text Channels",
|
||||||
|
|
@ -161,7 +162,8 @@ internal class NavigatorPreviewProvider : PreviewParameterProvider<NavigatorMode
|
||||||
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_9.jpg")),
|
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_9.jpg")),
|
||||||
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_10.jpg")),
|
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_10.jpg")),
|
||||||
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_11.jpg")),
|
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_11.jpg")),
|
||||||
)
|
),
|
||||||
|
selected = NavigatorModel.Guild(name = "Hu Tao", url = Res.getUri("drawable/placeholder_1.webp")) to null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThreePaneScene.kt
Normal file
120
ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThreePaneScene.kt
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package moe.lava.neon.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation3.runtime.NavEntry
|
||||||
|
import androidx.navigation3.scene.Scene
|
||||||
|
import androidx.navigation3.scene.SceneStrategy
|
||||||
|
import androidx.navigation3.scene.SceneStrategyScope
|
||||||
|
import androidx.window.core.layout.WindowSizeClass
|
||||||
|
|
||||||
|
private val listWidth = 350.dp
|
||||||
|
private val extraWidth = 300.dp
|
||||||
|
//private val minChatWidth = 250.dp
|
||||||
|
|
||||||
|
class ThreePaneScene<T : Any>(
|
||||||
|
override val key: Any,
|
||||||
|
override val previousEntries: List<NavEntry<T>>,
|
||||||
|
val listEntry: NavEntry<T>?,
|
||||||
|
val detailEntry: NavEntry<T>,
|
||||||
|
val extraEntry: NavEntry<T>?,
|
||||||
|
) : Scene<T> {
|
||||||
|
override val entries: List<NavEntry<T>> = listOfNotNull(listEntry, detailEntry, extraEntry)
|
||||||
|
override val content: @Composable (() -> Unit) = {
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (listEntry != null) {
|
||||||
|
Column(modifier = Modifier.width(listWidth)) {
|
||||||
|
listEntry.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = detailEntry,
|
||||||
|
contentKey = { entry -> entry.contentKey },
|
||||||
|
transitionSpec = {
|
||||||
|
slideInHorizontally(
|
||||||
|
initialOffsetX = { it }
|
||||||
|
) togetherWith
|
||||||
|
slideOutHorizontally(targetOffsetX = { -it })
|
||||||
|
}
|
||||||
|
){ entry ->
|
||||||
|
entry.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extraEntry != null) {
|
||||||
|
Column(modifier = Modifier.width(extraWidth)) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = extraEntry,
|
||||||
|
contentKey = { entry -> entry.contentKey },
|
||||||
|
transitionSpec = {
|
||||||
|
slideInHorizontally(
|
||||||
|
initialOffsetX = { it }
|
||||||
|
) togetherWith
|
||||||
|
slideOutHorizontally(targetOffsetX = { -it })
|
||||||
|
}
|
||||||
|
){ entry ->
|
||||||
|
entry.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIST_KEY = "ThreePaneScene-List"
|
||||||
|
private const val DETAIL_KEY = "ThreePaneScene-Detail"
|
||||||
|
private const val EXTRA_KEY = "ThreePaneScene-Extra"
|
||||||
|
|
||||||
|
class ThreePaneSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
|
||||||
|
override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
|
||||||
|
val useTwoPanes = when {
|
||||||
|
windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_LARGE_LOWER_BOUND)
|
||||||
|
-> false
|
||||||
|
windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
|
||||||
|
-> true
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: return null
|
||||||
|
val detailEntry = entries.findLast { it.metadata.containsKey(DETAIL_KEY) } ?: return null
|
||||||
|
val extraEntry = entries.findLast { it.metadata.containsKey(EXTRA_KEY) }
|
||||||
|
|
||||||
|
val sceneKey = listEntry.contentKey
|
||||||
|
|
||||||
|
return ThreePaneScene(
|
||||||
|
key = sceneKey,
|
||||||
|
previousEntries = entries.dropLast(1),
|
||||||
|
listEntry = listEntry.takeIf { !(useTwoPanes && extraEntry != null) },
|
||||||
|
// listEntry = listEntry,
|
||||||
|
detailEntry = detailEntry,
|
||||||
|
extraEntry = extraEntry,
|
||||||
|
// extraEntry = extraEntry.takeIf { !useTwoPanes },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun listPane() = mapOf(LIST_KEY to true)
|
||||||
|
fun detailPane() = mapOf(DETAIL_KEY to true)
|
||||||
|
fun extraPane() = mapOf(EXTRA_KEY to true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T : Any> rememberThreePaneSceneStrategy(): ThreePaneSceneStrategy<T> {
|
||||||
|
val windowSizeClass = currentWindowAdaptiveInfo(true).windowSizeClass
|
||||||
|
|
||||||
|
return remember(windowSizeClass) {
|
||||||
|
ThreePaneSceneStrategy(windowSizeClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue