feat(ui): full adaptive layout prototype

This commit is contained in:
Cilly Leang 2026-01-31 15:31:54 +11:00
parent fe46a32eba
commit 7535d8342e
Signed by: cilly
GPG key ID: 6500251E087653C9
9 changed files with 415 additions and 21 deletions

View file

@ -3,6 +3,7 @@ package moe.lava.neon.ui
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.screens.Login
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.NavigatorModel
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
private object Route {
object Route {
@Serializable
data object Login : NavKey
@ -35,6 +40,12 @@ private object Route {
@Serializable
data class Navigator(val left: Boolean) : NavKey
@Serializable
data object Chat : NavKey
@Serializable
data object MembersList : NavKey
}
private val config = SavedStateConfiguration {
@ -43,11 +54,13 @@ private val config = SavedStateConfiguration {
subclass(Route.Login::class, Route.Login.serializer())
subclass(Route.Sample::class, Route.Sample.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
fun App() {
Thread.setDefaultUncaughtExceptionHandler { t: Thread, e: Throwable ->
@ -64,7 +77,8 @@ fun App() {
motionScheme = MotionScheme.expressive(),
) {
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(
backStack = backStack,
entryDecorators = listOf(
@ -72,6 +86,7 @@ fun App() {
rememberViewModelStoreNavEntryDecorator(),
),
onBack = { backStack.removeLastOrNull() },
sceneStrategy = threePaneStrategy,
entryProvider = entryProvider {
entry<Route.Login> {
Login(
@ -85,6 +100,8 @@ fun App() {
Sample(
navTest = {
backStack.add(Route.Navigator(it))
backStack.add(Route.Chat)
backStack.add(Route.MembersList)
},
onRequestLogout = {
backStack.clear()
@ -92,7 +109,10 @@ fun App() {
}
)
}
entry<Route.Navigator> { key ->
entry<Route.Navigator>(
metadata = ThreePaneSceneStrategy.listPane()
) { key ->
if (key.left) {
Navigator(
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()
}
}
)
}

View 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()
}
}
}
}
}
}

View file

@ -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")
}
}
}

View file

@ -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()
}
}

View file

@ -42,8 +42,13 @@ fun Channels(
model: NavigatorModel,
bottomSpace: Dp,
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(
modifier = Modifier
@ -87,7 +92,8 @@ fun Channels(
Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) {
cat.channels.forEachIndexed { idx, ch ->
SegmentedListItem(
onClick = {},
selected = model.selected.second == ch,
onClick = { onNavigate(ch) },
colors = colors,
leadingContent = {
val res = when (ch.type) {

View file

@ -43,9 +43,9 @@ fun GuildButton(
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val target = when {
isHovered -> 0.8f
isPressed -> 0.85f
selected -> 1f
isHovered -> 0.8f
else -> 0f
}
val fadingIn = isPressed && !selected || !isPressed && selected

View file

@ -30,7 +30,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -47,6 +47,7 @@ data class NavigatorModel(
val guildName: String,
val channels: List<Category>,
val guilds: List<Guild>,
val selected: Pair<Guild, Channel?>,
) {
data class Category(
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)
@Composable
fun Navigator(
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) {
NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(model)
NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator(model)
NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(
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)
@Composable
private fun BottomSheetNavigator(
model: NavigatorModel
model: NavigatorModel,
onNavigateGuild: (NavigatorModel.Guild) -> Unit,
onNavigateChannel: (NavigatorModel.Channel) -> Unit,
) {
val morph = remember {
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
@ -97,7 +115,6 @@ private fun BottomSheetNavigator(
val offsetDp = with (LocalDensity.current) { offset.toDp() } - TopAppBarDefaults.TopAppBarExpandedHeight
BottomSheetScaffold(
sheetContent = {
var selected by remember { mutableIntStateOf(-1) }
LazyRow(
Modifier
.fillMaxWidth()
@ -110,8 +127,8 @@ private fun BottomSheetNavigator(
GuildButton(
model = guild,
morph = morph,
selected = selected == idx,
onSelected = { selected = idx }
selected = model.selected.first == guild,
onSelected = { onNavigateGuild(guild) }
)
}
}
@ -125,20 +142,26 @@ private fun BottomSheetNavigator(
)
},
) { innerPadding ->
Channels(model, offsetDp, innerPadding)
Channels(
model = model,
bottomSpace = offsetDp,
padding = innerPadding,
onNavigate = { onNavigateChannel(it) }
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun SidebarNavigator(
model: NavigatorModel
model: NavigatorModel,
onNavigateGuild: (NavigatorModel.Guild) -> Unit,
onNavigateChannel: (NavigatorModel.Channel) -> Unit,
) {
val morph = remember {
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
}
Row(Modifier.fillMaxSize()) {
var selected by remember { mutableIntStateOf(-1) }
LazyColumn(
Modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
@ -153,8 +176,8 @@ private fun SidebarNavigator(
GuildButton(
model = guild,
morph = morph,
selected = selected == idx,
onSelected = { selected = idx }
selected = model.selected.first == guild,
onSelected = { onNavigateGuild(guild) }
)
}
}
@ -165,7 +188,7 @@ private fun SidebarNavigator(
)
},
) { innerPadding ->
Channels(model, 0.dp, innerPadding)
Channels(model, 0.dp, innerPadding, onNavigate = { onNavigateChannel(it) })
}
}
}

View file

@ -47,6 +47,7 @@ internal class NavigatorPreviewProvider : PreviewParameterProvider<NavigatorMode
NavigatorModel.Guild(name = "Hutao", url = ""),
NavigatorModel.Guild(name = "huuutao", url = ""),
),
selected = NavigatorModel.Guild(name = "Hu Tao", url = "") to null,
channels = listOf(
NavigatorModel.Category(
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_10.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,
)
}

View 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)
}
}