refactor: add a custom back stack implementation

This commit is contained in:
Harsh Shandilya 2025-05-25 17:42:24 +05:30
parent eef3fa0fc8
commit e011870986
5 changed files with 102 additions and 26 deletions

View file

@ -20,19 +20,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
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.navigation3.runtime.NavKey
import dev.chrisbanes.haze.HazeDefaults
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack
import dev.msfjarvis.claw.android.ui.navigation.Destination
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
import kotlinx.collections.immutable.ImmutableList
@ -41,7 +40,7 @@ const val AnimationDuration = 100
@Composable
fun ClawNavigationBar(
backStack: SnapshotStateList<NavKey>,
backStack: ClawBackStack<Destination>,
items: ImmutableList<NavigationItem>,
isVisible: Boolean,
hazeState: HazeState,

View file

@ -19,15 +19,15 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation3.runtime.NavKey
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack
import dev.msfjarvis.claw.android.ui.navigation.Destination
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ClawNavigationRail(
backStack: SnapshotStateList<NavKey>,
backStack: ClawBackStack<Destination>,
items: ImmutableList<NavigationItem>,
isVisible: Boolean,
modifier: Modifier = Modifier,

View file

@ -0,0 +1,75 @@
/*
* Copyright © 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.android.ui.navigation
import androidx.compose.runtime.mutableStateListOf
import androidx.navigation3.runtime.NavKey
import io.github.aakira.napier.Napier
/**
* Naive implementation of a simple navigation back stack, backed by a
* [androidx.compose.runtime.snapshots.SnapshotStateList].
*
* Using a [java.util.Stack] would've made the API ridiculously simpler, but we would lose all the
* cool Compose benefits.
*
* Stacks function in a completely opposite order of lists, which means when you call
* first/firstOrNull on a stack, you expect to receive the item you added *last*, since stacks add
* to the front while lists add behind. To counter these expectations with the actual backing data
* structure, many APIs in this class inverse of identically named functions on [List].
*/
class ClawBackStack<T : NavKey>(startRoute: T) {
interface TopLevelRoute
val backStack = mutableStateListOf(startRoute)
/** Pushes a new destination onto the stack. */
fun add(route: T) {
logCurrentState("add")
backStack.add(route)
}
/** Checks if the "top" item in the back stack is an instance of [TopLevelRoute]. */
fun isOnTopLevelRoute(): Boolean {
logCurrentState("hasTopLevelDestination")
val top = firstOrNull()
return (top != null && top is TopLevelRoute)
}
fun firstOrNull(): T? {
logCurrentState("firstOrNull")
return backStack.lastOrNull()
}
fun lastOrNull(): T? {
logCurrentState("lastOrNull")
return backStack.firstOrNull()
}
fun removeLastOrNull(): T? {
logCurrentState("removeLastOrNull")
return backStack.removeLastOrNull()
}
// TODO(msfjarvis): Remove before shipping
private fun logCurrentState(methodName: String) {
val backStack = this.backStack
Napier.d(tag = LOG_TAG) {
buildString {
appendLine("State of ClawBackStack(${this@ClawBackStack})")
appendLine("Caller: $methodName")
appendLine("Top: ${backStack.firstOrNull()}")
appendLine("Bottom: ${backStack.lastOrNull()}")
appendLine("Current entries: ${backStack.joinToString(", ")}")
}
}
}
private companion object {
private const val LOG_TAG = "ClawBackStack"
}
}

View file

@ -21,11 +21,11 @@ import kotlinx.serialization.Serializable
sealed interface Destination : Parcelable, NavKey
@Parcelize @Serializable data object Hottest : Destination
@Parcelize @Serializable data object Hottest : Destination, ClawBackStack.TopLevelRoute
@Parcelize @Serializable data object Newest : Destination
@Parcelize @Serializable data object Newest : Destination, ClawBackStack.TopLevelRoute
@Parcelize @Serializable data object Saved : Destination
@Parcelize @Serializable data object Saved : Destination, ClawBackStack.TopLevelRoute
@Parcelize @Serializable data class Comments(val postId: String) : Destination

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.entry
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
@ -63,6 +62,7 @@ import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Destination
@ -90,7 +90,7 @@ fun Nav3Screen(
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
) {
val backStack = rememberNavBackStack<Destination>(Hottest)
val clawBackStack = ClawBackStack<Destination>(Hottest)
// region Pain
val context = LocalContext.current
@ -111,7 +111,7 @@ fun Nav3Screen(
val postIdOverride = activity?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY)
LaunchedEffect(Unit) {
if (postIdOverride != null) {
backStack.add(Comments(postIdOverride))
clawBackStack.add(Comments(postIdOverride))
}
}
@ -135,7 +135,7 @@ fun Nav3Screen(
// endregion
val postActions = remember {
PostActions(context, urlLauncher, viewModel) { backStack.add(Comments(it)) }
PostActions(context, urlLauncher, viewModel) { clawBackStack.add(Comments(it)) }
}
Scaffold(
@ -143,8 +143,10 @@ fun Nav3Screen(
TopAppBar(
modifier = Modifier.shadow(8.dp),
navigationIcon = {
if (backStack.firstOrNull() !in navDestinations) {
IconButton(onClick = { if (backStack.removeLastOrNull() == null) activity?.finish() }) {
if (!(clawBackStack.isOnTopLevelRoute())) {
IconButton(
onClick = { if (clawBackStack.removeLastOrNull() == null) activity?.finish() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to previous screen",
@ -159,16 +161,16 @@ fun Nav3Screen(
}
},
title = {
if (backStack.firstOrNull() in navDestinations) {
if (clawBackStack.isOnTopLevelRoute()) {
Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
}
},
actions = {
if (backStack.firstOrNull() in navDestinations) {
IconButton(onClick = { backStack.add(Search) }) {
if (clawBackStack.isOnTopLevelRoute()) {
IconButton(onClick = { clawBackStack.add(Search) }) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search posts")
}
IconButton(onClick = { backStack.add(Settings) }) {
IconButton(onClick = { clawBackStack.add(Settings) }) {
Icon(imageVector = Icons.Filled.Tune, contentDescription = "Settings")
}
}
@ -178,9 +180,9 @@ fun Nav3Screen(
bottomBar = {
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
ClawNavigationBar(
backStack,
clawBackStack,
items = navItems,
isVisible = backStack.firstOrNull() in navDestinations,
isVisible = clawBackStack.isOnTopLevelRoute(),
hazeState = hazeState,
)
}
@ -189,9 +191,9 @@ fun Nav3Screen(
modifier = Modifier.semantics { testTagsAsResourceId = true },
) { contentPadding ->
NavDisplay(
backStack = backStack,
backStack = clawBackStack.backStack,
modifier = modifier.hazeSource(hazeState),
onBack = { backStack.removeLastOrNull() },
onBack = { clawBackStack.removeLastOrNull() },
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(200)) togetherWith
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200))
@ -229,7 +231,7 @@ fun Nav3Screen(
SettingsScreen(
openInputStream = context.contentResolver::openInputStream,
openOutputStream = context.contentResolver::openOutputStream,
openLibrariesScreen = { backStack.add(AboutLibraries) },
openLibrariesScreen = { clawBackStack.add(AboutLibraries) },
importPosts = viewModel::importPosts,
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
@ -245,7 +247,7 @@ fun Nav3Screen(
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding,
openUserProfile = { backStack.add(User(it)) },
openUserProfile = { clawBackStack.add(User(it)) },
)
}
entry<User>(
@ -266,7 +268,7 @@ fun Nav3Screen(
username = dest.username,
getProfile = viewModel::getUserProfile,
contentPadding = contentPadding,
openUserProfile = { backStack.add(User(it)) },
openUserProfile = { clawBackStack.add(User(it)) },
)
}
entry<Search> {