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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context
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.interaction.MutableInteractionSource
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
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.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
@ -269,6 +271,7 @@ fun NavDrawer(
preferences: UserPreferences,
user: JellyfinUser,
server: JellyfinServer,
drawerState: DrawerState,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
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
),
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val density = LocalDensity.current
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
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
@ -302,51 +303,59 @@ fun NavDrawer(
viewModel.setShowMore(false)
}
val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth
val drawerBackground by animateColorAsState(
if (drawerState.isOpen) {
MaterialTheme.colorScheme.surface
} else {
Color.Transparent
},
val closedDrawerWidth = CollapsedDrawerItemWidth
val openDrawerWidth = ExpandedDrawerItemWidth
val offset by animateIntOffsetAsState(
targetValue =
IntOffset(
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 density = LocalDensity.current
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(
modifier = modifier,
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) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacedBy),
modifier =
Modifier
.fillMaxHeight()
.drawBehind {
drawRect(drawerBackground)
},
modifier = Modifier.fillMaxHeight(),
) {
// Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() }
@ -355,7 +364,7 @@ fun NavDrawer(
user = user,
imageUrl = userImageUrl,
serverName = server.name ?: server.url,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setupNavigationManager.navigateTo(
@ -393,7 +402,7 @@ fun NavDrawer(
text = stringResource(R.string.search),
icon = Icons.Default.Search,
selected = selectedIndex == -2,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-2)
@ -414,7 +423,7 @@ fun NavDrawer(
text = stringResource(R.string.home),
icon = Icons.Default.Home,
selected = selectedIndex == -1,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-1)
@ -438,7 +447,7 @@ fun NavDrawer(
library = it,
selected = selectedIndex == index,
moreExpanded = showMore,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, it)
@ -459,10 +468,10 @@ fun NavDrawer(
library = it,
selected = selectedIndex == adjustedIndex,
moreExpanded = showMore,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor =
if (drawerState.isOpen) {
if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
Color.Unspecified
@ -483,7 +492,7 @@ fun NavDrawer(
text = stringResource(R.string.settings),
icon = Icons.Default.Settings,
selected = false,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.navigationManager.navigateTo(
@ -501,10 +510,7 @@ fun NavDrawer(
},
) {
Box(
modifier =
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
) {
// Drawer content
DestinationContent(
@ -513,7 +519,10 @@ fun NavDrawer(
onClearBackdrop = onClearBackdrop,
modifier =
Modifier
.fillMaxSize(),
.fillMaxSize()
.offset {
offset
}.padding(start = closedDrawerWidth + 8.dp, end = 16.dp),
)
if (preferences.appPreferences.interfacePreferences.showClock) {
TimeDisplay()
@ -542,7 +551,7 @@ fun NavigationDrawerScope.ProfileIcon(
name = user.name,
imageUrl = imageUrl,
alpha = if (drawerOpen) 1f else .5f,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent = {
@ -583,7 +592,7 @@ fun NavigationDrawerScope.IconNavItem(
icon,
contentDescription = null,
tint = color,
modifier = Modifier,
modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent =
@ -673,7 +682,7 @@ fun NavigationDrawerScope.NavItem(
painter = painterResource(icon),
contentDescription = null,
tint = color,
modifier = Modifier,
modifier = Modifier.size(DrawerIconSize),
)
}
}
@ -700,6 +709,7 @@ fun NavigationDrawerScope.NavItem(
}
@Composable
@ReadOnlyComposable
fun navItemColor(
selected: 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,
)