Navigation drawer improvements (#842)

## Description
This updates the navigation drawer with better behavior and UI. It is
also a bit more optimized and is slightly faster.

- When open, content is pushed to the right instead of being covered
- When open, it uses the same background colors from the dynamic
background
- The icons no longer shift as the drawer opens & closes
- The open drawer is not as wide
- Focus highlight on items is less circular, now matching the other
menus in the app
- The animation is a bit faster too

Dev notes:
- This PR copies & modifies `tv-material` code, so there's a customized
`ModalNavigationDrawer` implementation now
- There are fewer `AnimatedVisibility` composables which improves
performance
- Icons are exactly sized
- Content pages generally shouldn't add start/end padding anymore, the
padding is now added by the nav drawer
(b4f1b111edcd7e7ec05edc1100402b2851721fcd) and is consistent across
every page

### Related issues
Closes #699
Closes #384

### Testing
Tested on the emulator, nvidia shield, & google sabrina

## Screenshots
![nav_drawer_closed
Large](https://github.com/user-attachments/assets/9047eb45-6025-4992-8740-3430f0a8f2a9)
![nav_drawer_open_backdrop_dim
Large](https://github.com/user-attachments/assets/880f24a5-8018-469e-805d-18060c641dc5)



## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-08 13:03:43 -05:00 committed by GitHub
parent 8e9a6799f5
commit d5602aa88c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 334 additions and 117 deletions

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -58,7 +57,7 @@ fun <T> ItemRow(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier,
) )
LazyRow( LazyRow(
state = state, state = state,

View file

@ -274,7 +274,7 @@ fun <T : CardGridItem> CardGrid(
horizontalArrangement = Arrangement.spacedBy(spacing), horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing), verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState, state = gridState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(vertical = 16.dp),
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -394,7 +394,7 @@ fun <T : CardGridItem> CardGrid(
modifier = modifier =
Modifier Modifier
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(end = 16.dp), .padding(start = 16.dp),
// Add end padding to push away from edge // Add end padding to push away from edge
letterClicked = { letter -> letterClicked = { letter ->
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -40,9 +38,7 @@ fun CollectionFolderBoxSet(
recursive = recursive, recursive = recursive,
sortOptions = BoxSetSortOptions, sortOptions = BoxSetSortOptions,
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@ -53,9 +51,7 @@ fun CollectionFolderGeneric(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = sortOptions, sortOptions = sortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -134,7 +134,7 @@ fun CollectionFolderLiveTv(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title }, tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -176,9 +176,7 @@ fun CollectionFolderLiveTv(
showTitle = false, showTitle = false,
recursive = false, recursive = false,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,
modifier = modifier = Modifier,
Modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -83,7 +83,7 @@ fun CollectionFolderMovie(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -101,7 +101,6 @@ fun CollectionFolderMovie(
}, },
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
@ -129,7 +128,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -162,7 +160,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -180,7 +177,6 @@ fun CollectionFolderMovie(
includeItemTypes = listOf(BaseItemKind.MOVIE), includeItemTypes = listOf(BaseItemKind.MOVIE),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@ -74,9 +72,7 @@ fun CollectionFolderPhotoAlbum(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = sortOptions, sortOptions = sortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderPlaylist(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = PlaylistSortOptions, sortOptions = PlaylistSortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderRecordings(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = MovieSortOptions, sortOptions = MovieSortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -87,7 +87,7 @@ fun CollectionFolderTv(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -105,7 +105,6 @@ fun CollectionFolderTv(
}, },
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
@ -129,7 +128,6 @@ fun CollectionFolderTv(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -150,7 +148,6 @@ fun CollectionFolderTv(
includeItemTypes = listOf(BaseItemKind.SERIES), includeItemTypes = listOf(BaseItemKind.SERIES),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )

View file

@ -298,9 +298,7 @@ fun PersonPageContent(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader, userScrollEnabled = !focusedOnHeader,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
) { ) {
item { item {
PersonHeader( PersonHeader(

View file

@ -373,7 +373,6 @@ fun PlaylistDetailsContent(
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
PlaylistDetailsHeader( PlaylistDetailsHeader(
@ -386,7 +385,7 @@ fun PlaylistDetailsContent(
getPossibleFilterValues = getPossibleFilterValues, getPossibleFilterValues = getPossibleFilterValues,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp, top = 80.dp) .padding(top = 80.dp)
.fillMaxWidth(.25f), .fillMaxWidth(.25f),
) )
when (loadingState) { when (loadingState) {

View file

@ -303,7 +303,7 @@ fun EpisodeDetailsContent(
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {

View file

@ -410,7 +410,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {

View file

@ -333,7 +333,7 @@ fun SeriesDetailsContent(
Column( Column(
modifier = modifier =
Modifier Modifier
.padding(horizontal = 24.dp, vertical = 16.dp) .padding(vertical = 16.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
LazyColumn( LazyColumn(

View file

@ -127,7 +127,7 @@ fun SeriesOverviewContent(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(vertical = 16.dp)
.focusGroup() .focusGroup()
.nestedScroll(scrollConnection) .nestedScroll(scrollConnection)
.verticalScroll(scrollState) .verticalScroll(scrollState)
@ -142,9 +142,9 @@ fun SeriesOverviewContent(
) { ) {
val paddingValues = val paddingValues =
if (preferences.appPreferences.interfacePreferences.showClock) { if (preferences.appPreferences.interfacePreferences.showClock) {
PaddingValues(start = 16.dp, end = 100.dp) PaddingValues(start = 0.dp, end = 184.dp)
} else { } else {
PaddingValues(start = 16.dp, end = 16.dp) PaddingValues(start = 0.dp, end = 16.dp)
} }
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -236,7 +236,7 @@ fun HomePageContent(
item = focusedItem, item = focusedItem,
modifier = modifier =
Modifier Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 32.dp) .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f), .fillMaxHeight(.33f),
) )
LazyColumn( LazyColumn(
@ -244,9 +244,6 @@ fun HomePageContent(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 24.dp,
end = 16.dp,
top = 0.dp,
bottom = Cards.height2x3, bottom = Cards.height2x3,
), ),
modifier = modifier =

View file

@ -11,13 +11,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -32,7 +34,9 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.rememberDrawerState
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
@ -91,6 +95,7 @@ fun ApplicationContent(
navigationManager.backStack = backStack navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
val drawerState = rememberDrawerState(DrawerValue.Closed)
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
@ -181,9 +186,15 @@ fun ApplicationContent(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.fillMaxHeight(.7f) .fillMaxHeight(.7f)
.fillMaxWidth(.7f) .fillMaxWidth(.7f)
.alpha(.95f) .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent { .drawWithContent {
drawContent() drawContent()
if (drawerState.isOpen) {
drawRect(
brush = SolidColor(Color.Black),
alpha = .75f,
)
}
// Subtle top scrim for system UI readability (clock, tabs) // Subtle top scrim for system UI readability (clock, tabs)
if (enableTopScrim) { if (enableTopScrim) {
drawRect( drawRect(
@ -245,6 +256,7 @@ fun ApplicationContent(
preferences = preferences, preferences = preferences,
user = user, user = user,
server = server, server = server,
drawerState = drawerState,
onClearBackdrop = viewModel::clearBackdrop, onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -12,7 +14,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -24,13 +28,13 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
@ -44,6 +48,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -56,13 +61,10 @@ import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
@ -269,6 +271,7 @@ fun NavDrawer(
preferences: UserPreferences, preferences: UserPreferences,
user: JellyfinUser, user: JellyfinUser,
server: JellyfinServer, server: JellyfinServer,
drawerState: DrawerState,
onClearBackdrop: () -> Unit, onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
@ -277,13 +280,11 @@ fun NavDrawer(
key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
), ),
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchFocusRequester = remember { FocusRequester() }
// If the user presses back while on the home page, open the nav drawer, another back press will quit the app // If the user presses back while on the home page, open the nav drawer, another back press will quit the app
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
@ -302,51 +303,59 @@ fun NavDrawer(
viewModel.setShowMore(false) viewModel.setShowMore(false)
} }
val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth val closedDrawerWidth = CollapsedDrawerItemWidth
val drawerBackground by animateColorAsState( val openDrawerWidth = ExpandedDrawerItemWidth
if (drawerState.isOpen) { val offset by animateIntOffsetAsState(
MaterialTheme.colorScheme.surface targetValue =
} else { IntOffset(
Color.Transparent x =
}, with(density) {
if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0
},
y = 0,
),
animationSpec =
spring(
stiffness = DrawerAnimationStiffness,
visibilityThreshold = IntOffset.VisibilityThreshold,
),
) )
val spacedBy = 4.dp
val config = LocalConfiguration.current val config = LocalConfiguration.current
val density = LocalDensity.current
val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
suspend fun scrollToSelected() {
val target = selectedIndex + 2
try {
if (target !in
listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex
) {
val mult = if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1
listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2))
}
} catch (ex: Exception) {
Timber.w(ex, "Error scrolling to %s", target)
}
}
LaunchedEffect(selectedIndex) {
scrollToSelected()
}
ModalNavigationDrawer( ModalNavigationDrawer(
modifier = modifier, modifier = modifier,
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = { drawerValue ->
val isOpen = drawerValue.isOpen
val spacedBy = 4.dp
val listState = rememberLazyListState()
val searchFocusRequester = remember { FocusRequester() }
suspend fun scrollToSelected() {
val target = selectedIndex + 2
try {
if (target !in
listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex
) {
val mult =
if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1
listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2))
}
} catch (ex: Exception) {
Timber.w(ex, "Error scrolling to %s", target)
}
}
LaunchedEffect(selectedIndex) {
scrollToSelected()
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) { ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacedBy), verticalArrangement = Arrangement.spacedBy(spacedBy),
modifier = modifier = Modifier.fillMaxHeight(),
Modifier
.fillMaxHeight()
.drawBehind {
drawRect(drawerBackground)
},
) { ) {
// Even though some must be clicked, focusing on it should clear other focused items // Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -355,7 +364,7 @@ fun NavDrawer(
user = user, user = user,
imageUrl = userImageUrl, imageUrl = userImageUrl,
serverName = server.name ?: server.url, serverName = server.name ?: server.url,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setupNavigationManager.navigateTo( viewModel.setupNavigationManager.navigateTo(
@ -393,7 +402,7 @@ fun NavDrawer(
text = stringResource(R.string.search), text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
selected = selectedIndex == -2, selected = selectedIndex == -2,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-2) viewModel.setIndex(-2)
@ -414,7 +423,7 @@ fun NavDrawer(
text = stringResource(R.string.home), text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
selected = selectedIndex == -1, selected = selectedIndex == -1,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-1) viewModel.setIndex(-1)
@ -438,7 +447,7 @@ fun NavDrawer(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.onClickDrawerItem(index, it) viewModel.onClickDrawerItem(index, it)
@ -459,10 +468,10 @@ fun NavDrawer(
library = it, library = it,
selected = selectedIndex == adjustedIndex, selected = selectedIndex == adjustedIndex,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor = containerColor =
if (drawerState.isOpen) { if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else { } else {
Color.Unspecified Color.Unspecified
@ -483,7 +492,7 @@ fun NavDrawer(
text = stringResource(R.string.settings), text = stringResource(R.string.settings),
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
selected = false, selected = false,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
@ -501,10 +510,7 @@ fun NavDrawer(
}, },
) { ) {
Box( Box(
modifier = modifier = Modifier.fillMaxSize(),
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
) { ) {
// Drawer content // Drawer content
DestinationContent( DestinationContent(
@ -513,7 +519,10 @@ fun NavDrawer(
onClearBackdrop = onClearBackdrop, onClearBackdrop = onClearBackdrop,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize()
.offset {
offset
}.padding(start = closedDrawerWidth + 8.dp, end = 16.dp),
) )
if (preferences.appPreferences.interfacePreferences.showClock) { if (preferences.appPreferences.interfacePreferences.showClock) {
TimeDisplay() TimeDisplay()
@ -542,7 +551,7 @@ fun NavigationDrawerScope.ProfileIcon(
name = user.name, name = user.name,
imageUrl = imageUrl, imageUrl = imageUrl,
alpha = if (drawerOpen) 1f else .5f, alpha = if (drawerOpen) 1f else .5f,
modifier = Modifier.fillMaxSize(), modifier = Modifier.size(DrawerIconSize),
) )
}, },
supportingContent = { supportingContent = {
@ -583,7 +592,7 @@ fun NavigationDrawerScope.IconNavItem(
icon, icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier, modifier = Modifier.size(DrawerIconSize),
) )
}, },
supportingContent = supportingContent =
@ -673,7 +682,7 @@ fun NavigationDrawerScope.NavItem(
painter = painterResource(icon), painter = painterResource(icon),
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier, modifier = Modifier.size(DrawerIconSize),
) )
} }
} }
@ -700,6 +709,7 @@ fun NavigationDrawerScope.NavItem(
} }
@Composable @Composable
@ReadOnlyComposable
fun navItemColor( fun navItemColor(
selected: Boolean, selected: Boolean,
focused: Boolean, focused: Boolean,
@ -753,4 +763,6 @@ fun navItemColor(
} }
} }
val DrawerState.isOpen: Boolean get() = this.currentValue == DrawerValue.Open val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen
val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open

View file

@ -0,0 +1,229 @@
/*
* This file contains code copied and modified from:
* https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-androidx-tv-material-release/tv/tv-material/src/main/java/androidx/tv/material3/
*
* Their license & required attribution is below.
*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.tv.material3.DrawerState
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.NavigationDrawerItemColors
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.rememberDrawerState
@Composable
fun ModalNavigationDrawer(
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
content: @Composable () -> Unit,
) {
Box(modifier = modifier) {
DrawerSheet(
modifier =
Modifier
.align(Alignment.CenterStart),
drawerState = drawerState,
content = drawerContent,
)
content()
}
}
@Composable
private fun DrawerSheet(
modifier: Modifier = Modifier,
drawerState: DrawerState = remember { DrawerState() },
content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
) {
// indicates that the drawer has been set to its initial state and has grabbed focus if
// necessary. Controls whether focus is used to decide the state of the drawer going forward.
var initializationComplete: Boolean by remember { mutableStateOf(false) }
var focusState by remember { mutableStateOf<FocusState?>(null) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(key1 = drawerState.currentValue) {
if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) {
// used to grab focus if the drawer state is set to Open on start.
focusRequester.requestFocus()
}
initializationComplete = true
}
val internalModifier =
Modifier
.focusRequester(focusRequester)
.animateContentSize(
animationSpec =
spring(
stiffness = DrawerAnimationStiffness,
visibilityThreshold = IntSize.VisibilityThreshold,
),
).fillMaxHeight()
// adding passed-in modifier here to ensure animateContentSize is called before other
// size based modifiers.
.then(modifier)
.onFocusChanged {
focusState = it
if (initializationComplete) {
drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed)
}
}.focusGroup()
Box(modifier = internalModifier) {
NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply {
content(drawerState.currentValue)
}
}
}
internal class NavigationDrawerScopeImpl(
override val hasFocus: Boolean,
) : NavigationDrawerScope
internal val CollapsedDrawerItemWidth = 64.dp
internal val ExpandedDrawerItemWidth = 224.dp
internal val DrawerIconSize = 24.dp
internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2
internal val DrawerAnimationStiffness = Spring.StiffnessMedium
@Composable
internal fun NavigationDrawerScope.NavigationDrawerItem(
selected: Boolean,
onClick: () -> Unit,
leadingContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
supportingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation,
colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit,
) {
val animatedWidth by
animateDpAsState(
targetValue =
if (hasFocus) {
ExpandedDrawerItemWidth
} else {
CollapsedDrawerItemWidth
},
label = "NavigationDrawerItem width open/closed state of the drawer item",
)
val navDrawerItemHeight =
if (supportingContent == null) {
NavigationDrawerItemDefaults.ContainerHeightOneLine
} else {
NavigationDrawerItemDefaults.ContainerHeightTwoLine
}
ListItem(
selected = selected,
onClick = onClick,
headlineContent = content,
leadingContent = {
Box(
Modifier
.padding(horizontal = DrawerIconPadding)
.size(DrawerIconSize),
) { leadingContent() }
},
trailingContent = trailingContent,
supportingContent = supportingContent,
modifier =
modifier
.layout { measurable, constraints ->
val width = animatedWidth.roundToPx()
val height = navDrawerItemHeight.roundToPx()
val placeable =
measurable.measure(
constraints.copy(
minWidth = width,
maxWidth = width,
minHeight = height,
maxHeight = height,
),
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
},
enabled = enabled,
onLongClick = onLongClick,
tonalElevation = tonalElevation,
colors = colors.toToggleableListItemColors(hasFocus),
scale = ListItemDefaults.scale(1f, 1f),
interactionSource = interactionSource,
)
}
@Composable
private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) =
ListItemDefaults.colors(
containerColor = containerColor,
contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor,
focusedContainerColor = focusedContainerColor,
focusedContentColor = focusedContentColor,
pressedContainerColor = pressedContainerColor,
pressedContentColor = pressedContentColor,
selectedContainerColor = selectedContainerColor,
selectedContentColor = selectedContentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor =
if (doesNavigationDrawerHaveFocus) {
disabledContentColor
} else {
disabledInactiveContentColor
},
focusedSelectedContainerColor = focusedSelectedContainerColor,
focusedSelectedContentColor = focusedSelectedContentColor,
pressedSelectedContainerColor = pressedSelectedContainerColor,
pressedSelectedContentColor = pressedSelectedContentColor,
)