From e011870986bb20dbf5b88ec8bf410dc607e46f50 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Sun, 25 May 2025 17:42:24 +0530 Subject: [PATCH] refactor: add a custom back stack implementation --- .../ui/decorations/ClawNavigationBar.kt | 5 +- .../ui/decorations/ClawNavigationRail.kt | 6 +- .../android/ui/navigation/ClawBackStack.kt | 75 +++++++++++++++++++ .../claw/android/ui/navigation/Destination.kt | 6 +- .../claw/android/ui/screens/Nav3Screen.kt | 36 ++++----- 5 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/ClawBackStack.kt diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt index 3b7112b1..a41576b6 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt @@ -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, + backStack: ClawBackStack, items: ImmutableList, isVisible: Boolean, hazeState: HazeState, diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt index 345ef0f4..ff8aaf01 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt @@ -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, + backStack: ClawBackStack, items: ImmutableList, isVisible: Boolean, modifier: Modifier = Modifier, diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/ClawBackStack.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/ClawBackStack.kt new file mode 100644 index 00000000..d7b7db59 --- /dev/null +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/ClawBackStack.kt @@ -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(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" + } +} diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt index 4d5d05ce..62fe7d2a 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt @@ -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 diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/Nav3Screen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/Nav3Screen.kt index 01fa436e..f4d5c418 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/Nav3Screen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/Nav3Screen.kt @@ -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(Hottest) + val clawBackStack = ClawBackStack(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( @@ -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 {