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

View file

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