feat: redesign bottom navigation bar

This commit is contained in:
Harsh Shandilya 2024-08-28 15:16:42 +05:30
parent 4f3bafc051
commit 1de4916c9c
14 changed files with 211 additions and 64 deletions

View file

@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Change submitter text to 'authored' when applicable
- Unread comments now have a brighter background rather than a text badge
- Bottom navigation's visibility now interacts with the scroll behavior of the post lists
- Bottom navigation bar has been redesigned
## [1.48.0] - 2024-06-05

View file

@ -95,6 +95,8 @@ dependencies {
implementation(libs.copydown)
implementation(libs.dagger)
implementation(libs.eithernet)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.javax.inject)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core)

View file

@ -13,31 +13,37 @@ import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeChild
import dev.msfjarvis.claw.android.ui.navigation.Destination
import dev.msfjarvis.claw.android.ui.navigation.matches
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
import kotlinx.collections.immutable.ImmutableList
const val AnimationDuration = 100
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClawNavigationBar(
navController: NavController,
items: ImmutableList<NavigationItem>,
isVisible: Boolean,
scrollBehavior: BottomAppBarScrollBehavior,
hazeState: HazeState,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@ -54,9 +60,28 @@ fun ClawNavigationBar(
targetOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(durationMillis = AnimationDuration, easing = FastOutLinearInEasing),
),
modifier = Modifier,
label = "",
content = {
FloatingNavigationBar(
tonalElevation = 16.dp,
shape = MaterialTheme.shapes.extraLarge,
modifier =
modifier
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.clip(MaterialTheme.shapes.extraLarge)
.hazeChild(
hazeState,
style =
HazeStyle(
backgroundColor = MaterialTheme.colorScheme.surface,
tints = emptyList(),
blurRadius = 24.dp,
noiseFactor = 0f,
),
),
containerColor = Color.Transparent,
) {
BottomAppBar(modifier = modifier, scrollBehavior = scrollBehavior) {
val navBackStackEntry = navController.currentBackStackEntryAsState().value
val currentDestination = navBackStackEntry?.destination
items.forEach { navItem ->
@ -87,7 +112,8 @@ fun ClawNavigationBar(
)
}
}
}
},
)
}
class NavigationItem(

View file

@ -10,6 +10,7 @@ import androidx.activity.compose.ReportDrawn
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@ -36,6 +37,7 @@ fun DatabasePosts(
items: ImmutableMap<String, List<UIPost>>,
listState: LazyListState,
postActions: PostActions,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
ReportDrawn()
@ -50,7 +52,7 @@ fun DatabasePosts(
Text(text = "No saved posts", style = MaterialTheme.typography.headlineSmall)
}
} else {
LazyColumn(state = listState) {
LazyColumn(state = listState, contentPadding = contentPadding) {
items.forEach { (month, posts) ->
stickyHeader(contentType = "month-header") { MonthHeader(label = month) }
items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item ->

View file

@ -7,6 +7,7 @@
package dev.msfjarvis.claw.android.ui.lists
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -47,6 +48,7 @@ fun NetworkPosts(
lazyPagingItems: LazyPagingItems<UIPost>,
listState: LazyListState,
postActions: PostActions,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
@ -72,7 +74,7 @@ fun NetworkPosts(
modifier = Modifier.align(Alignment.Center),
)
} else {
LazyColumn(state = listState) {
LazyColumn(contentPadding = contentPadding, state = listState) {
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.shortId },
@ -109,6 +111,7 @@ private fun ListPreview() {
lazyPagingItems = flow.collectAsLazyPagingItems(),
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
contentPadding = PaddingValues(),
)
}
}

View file

@ -7,6 +7,7 @@
package dev.msfjarvis.claw.android.ui.lists
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.LazyListState
@ -32,6 +33,7 @@ fun SearchList(
searchQuery: String,
setSearchQuery: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
val lazyPagingItems = items.collectAsLazyPagingItems()
val triggerSearch = { query: String ->
@ -49,6 +51,7 @@ fun SearchList(
lazyPagingItems = lazyPagingItems,
listState = listState,
postActions = postActions,
contentPadding = contentPadding,
)
}
}

View file

@ -13,7 +13,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
@ -26,7 +25,6 @@ import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Whatshot
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -45,7 +43,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -61,6 +58,8 @@ import androidx.navigation.toRoute
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.android.SearchActivity
@ -111,8 +110,7 @@ fun LobstersPostsScreen(
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val postActions = rememberPostActions(context, urlLauncher, navController, viewModel)
val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
val hazeState = remember { HazeState() }
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
@ -209,17 +207,14 @@ fun LobstersPostsScreen(
navController = navController,
items = navItems,
isVisible = currentDestination.any(navDestinations),
scrollBehavior = scrollBehavior,
hazeState = hazeState,
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier =
modifier.nestedScroll(scrollBehavior.nestedScrollConnection).semantics {
testTagsAsResourceId = true
},
) { paddingValues ->
Row(modifier = Modifier.padding(paddingValues)) {
modifier = modifier.semantics { testTagsAsResourceId = true },
) { contentPadding ->
Row {
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) {
ClawNavigationRail(
navController = navController,
@ -234,6 +229,7 @@ fun LobstersPostsScreen(
// Make animations 2x faster than default specs
enterTransition = { fadeIn(animationSpec = tween(350)) },
exitTransition = { fadeOut(animationSpec = tween(350)) },
modifier = Modifier.haze(hazeState),
) {
composable<Hottest> {
setWebUri("https://lobste.rs/")
@ -241,6 +237,7 @@ fun LobstersPostsScreen(
lazyPagingItems = hottestPosts,
listState = hottestListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
composable<Newest> {
@ -249,11 +246,17 @@ fun LobstersPostsScreen(
lazyPagingItems = newestPosts,
listState = newestListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
composable<Saved> {
setWebUri(null)
DatabasePosts(items = savedPosts, listState = savedListState, postActions = postActions)
DatabasePosts(
items = savedPosts,
listState = savedListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
composable<Comments> { backStackEntry ->
val postId = backStackEntry.toRoute<Comments>().postId
@ -264,6 +267,7 @@ fun LobstersPostsScreen(
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding,
openUserProfile = { navController.navigate(User(it)) },
)
}
@ -273,6 +277,7 @@ fun LobstersPostsScreen(
UserProfile(
username = username,
getProfile = viewModel::getUserProfile,
contentPadding = contentPadding,
openUserProfile = { navController.navigate(User(it)) },
)
}
@ -284,9 +289,12 @@ fun LobstersPostsScreen(
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
snackbarHostState = snackbarHostState,
contentPadding = contentPadding,
)
}
composable<AboutLibraries> { LibrariesContainer(modifier = Modifier.fillMaxSize()) }
composable<AboutLibraries> {
LibrariesContainer(contentPadding = contentPadding, modifier = Modifier.fillMaxSize())
}
}
}
}

View file

@ -6,7 +6,6 @@
*/
package dev.msfjarvis.claw.android.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
@ -39,12 +38,8 @@ fun SearchScreen(
val navController = rememberNavController()
val postActions = rememberPostActions(LocalContext.current, urlLauncher, navController, viewModel)
val listState = rememberLazyListState()
Scaffold(modifier = modifier) { paddingValues ->
NavHost(
navController = navController,
startDestination = Search,
modifier = Modifier.padding(paddingValues),
) {
Scaffold(modifier = modifier) { contentPadding ->
NavHost(navController = navController, startDestination = Search) {
composable<Search> {
setWebUri("https://lobste.rs/search")
SearchList(
@ -52,6 +47,7 @@ fun SearchScreen(
listState = listState,
postActions = postActions,
searchQuery = viewModel.searchQuery,
contentPadding = contentPadding,
setSearchQuery = { query -> viewModel.searchQuery = query },
)
}
@ -65,6 +61,7 @@ fun SearchScreen(
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
openUserProfile = { navController.navigate(User(it)) },
contentPadding = contentPadding,
)
}
composable<User> { backStackEntry ->
@ -74,6 +71,7 @@ fun SearchScreen(
username = username,
getProfile = viewModel::getUserProfile,
openUserProfile = { navController.navigate(User(it)) },
contentPadding = contentPadding,
)
}
}

View file

@ -13,11 +13,13 @@ import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.Bookmarks
@ -55,10 +57,11 @@ fun SettingsScreen(
importPosts: suspend (InputStream) -> Unit,
exportPostsAsJson: suspend (OutputStream) -> Unit,
exportPostsAsHtml: suspend (OutputStream) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
Box(modifier = modifier.fillMaxSize()) {
Box(modifier = modifier.padding(contentPadding).fillMaxSize()) {
Column {
ListItem(
headlineContent = { Text("Data transfer") },

View file

@ -7,6 +7,7 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -34,6 +35,7 @@ fun CommentsPage(
htmlConverter: HTMLConverter,
getSeenComments: suspend (String) -> PostComments,
markSeenComments: (String, List<Comment>) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
openUserProfile: (String) -> Unit,
) {
@ -59,6 +61,7 @@ fun CommentsPage(
commentState = commentState,
markSeenComments = markSeenComments,
openUserProfile = openUserProfile,
contentPadding = contentPadding,
modifier = modifier.fillMaxSize(),
)
}

View file

@ -52,6 +52,7 @@ internal fun CommentsPageInternal(
commentState: PostComments,
markSeenComments: (String, List<Comment>) -> Unit,
openUserProfile: (String) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -68,7 +69,7 @@ internal fun CommentsPageInternal(
}
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
LazyColumn(modifier = modifier, contentPadding = PaddingValues(bottom = 24.dp)) {
LazyColumn(modifier = modifier, contentPadding = contentPadding) {
item {
CommentsHeader(
post = details,

View file

@ -0,0 +1,94 @@
/*
* Copyright © 2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.HeartBroken
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// Taken from Chris Banes' amazing app Tivi
// https://github.com/chrisbanes/tivi/blob/836d596d74959f4235ca2395b5bbfdd6fd9c9a9e/ui/root/src/commonMain/kotlin/app/tivi/home/Home.kt#L173
@Composable
fun FloatingNavigationBar(
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.extraLarge,
containerColor: Color = NavigationBarDefaults.containerColor,
contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor),
tonalElevation: Dp = NavigationBarDefaults.Elevation,
content: @Composable RowScope.() -> Unit,
) {
Surface(
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
shape = shape,
border =
BorderStroke(
width = 0.5.dp,
brush =
Brush.verticalGradient(
colors =
listOf(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
)
),
),
modifier = modifier,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth().height(80.dp).selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
@Preview
@Composable
private fun FloatingNavigationBarPreview() {
FloatingNavigationBar {
NavigationBarItem(
selected = true,
onClick = {},
icon = { Icon(imageVector = Icons.Filled.HeartBroken, contentDescription = "Home") },
)
NavigationBarItem(
selected = true,
onClick = {},
icon = { Icon(imageVector = Icons.Filled.BarChart, contentDescription = "Home") },
)
NavigationBarItem(
selected = true,
onClick = {},
icon = { Icon(imageVector = Icons.Filled.BrokenImage, contentDescription = "Home") },
)
}
}

View file

@ -9,6 +9,7 @@ package dev.msfjarvis.claw.common.user
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
@ -45,6 +46,7 @@ fun UserProfile(
username: String,
getProfile: suspend (username: String) -> User,
openUserProfile: (String) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val user by
@ -67,7 +69,7 @@ fun UserProfile(
}
is Error -> {
val error = user as Error
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
NetworkError(
label = error.description,
error = error.error,

View file

@ -73,6 +73,8 @@ copydown = "io.github.furstenheim:copy_down:1.1"
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" }
eithernet = "com.slack.eithernet:eithernet:1.9.0"
haze = "dev.chrisbanes.haze:haze:0.9.0-alpha08"
haze-materials = "dev.chrisbanes.haze:haze-materials:0.9.0-alpha08"
javax-inject = "javax.inject:javax.inject:1"
jsoup = "org.jsoup:jsoup:1.18.1"
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }