Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c453e48
Add a detail screen
stagg Aug 6, 2025
9dde251
Rework the content layout to be adaptive
stagg Aug 6, 2025
500a45d
Fix root reset
stagg Aug 6, 2025
30c6a24
Merge branch 'main' into j-sample-bottombar-adaptive
stagg Sep 8, 2025
303c8d8
Get it mostly working with `ListDetailPaneScaffold`
stagg Sep 9, 2025
18c5475
wip swipe to restore thing, trying a backstack record stash thing
stagg Sep 13, 2025
5693ede
WIP - Better approach by preventing the backstack pop
stagg Sep 16, 2025
df23f8c
Merge branch 'main' into j-sample-bottombar-adaptive
stagg Oct 17, 2025
bb4d5dc
Merge remote-tracking branch 'origin/main' into j-sample-bottombar-ad…
stagg Oct 17, 2025
5634d63
merge fixes
stagg Oct 17, 2025
e2f58bd
Add navigator as a param on the AnimatedNavDecorator.Factory create
stagg Oct 22, 2025
59b39fa
Make `AdaptiveNavDecoration` just be concerned with the ListDetail la…
stagg Oct 22, 2025
8df817e
gitignore
stagg Oct 22, 2025
e2fd5f0
retain pane position
stagg Oct 23, 2025
0406398
tidy up
stagg Oct 24, 2025
d7a7015
figure out some slide over stuff
stagg Oct 25, 2025
1b53545
Merge branch 'main' into j-sample-bottombar-adaptive
stagg Nov 13, 2025
87b0ba4
navstack
stagg Nov 13, 2025
33e22ab
Actual SaveableNavStack implementation
stagg Nov 14, 2025
121dbd7
NavStack everywhere
stagg Nov 14, 2025
5994879
Merge branch 'j-compose-110' into j-sample-bottombar-adaptive
stagg Nov 14, 2025
48bd6f1
Start wiring into NavEventHandler
stagg Nov 14, 2025
27b2112
Fix some things, break some more
stagg Nov 17, 2025
434f8df
notes
stagg Nov 18, 2025
fe6a1db
Shared NavStackList
stagg Nov 18, 2025
d8e05e3
some nav fixes
stagg Nov 18, 2025
b799929
kdoc
stagg Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ fastlane/report.xml
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm

# AI Stuff
/.claude/settings.local.json
/CLAUDE.local.md
2 changes: 1 addition & 1 deletion backstack/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ kotlin {
api(libs.compose.runtime)
api(libs.compose.ui)
api(libs.coroutines)
api(projects.circuitRuntimeScreen)
api(projects.circuitRuntime)
implementation(libs.compose.runtime.saveable)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class RememberSaveableBackstackTest {
fun backStackStartsWithRootScreen() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
val backStack = rememberSaveableBackStack(TestScreen.ScreenA)
backStack.toList()
backStack.peekNavStack()
}
.test { assertEquals(awaitItem().first().screen, TestScreen.ScreenA) }
}
Expand Down Expand Up @@ -52,8 +52,8 @@ class RememberSaveableBackstackTest {
val secondStack = awaitItem()

assertNotSame(firstStack, secondStack)
assertEquals(firstStack.toList().first().screen, TestScreen.ScreenA)
assertEquals(secondStack.toList().first().screen, TestScreen.ScreenB)
assertEquals(firstStack.peekNavStack().first().screen, TestScreen.ScreenA)
assertEquals(secondStack.peekNavStack().first().screen, TestScreen.ScreenB)
}
}

Expand All @@ -67,8 +67,8 @@ class RememberSaveableBackstackTest {
val secondStack = awaitItem()

assertNotSame(firstStack, secondStack)
assertEquals(firstStack.toList().first().screen, TestScreen.ScreenA)
assertEquals(secondStack.toList().first().screen, TestScreen.ScreenB)
assertEquals(firstStack.peekNavStack().first().screen, TestScreen.ScreenA)
assertEquals(secondStack.peekNavStack().first().screen, TestScreen.ScreenB)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,144 +16,17 @@
package com.slack.circuit.backstack

import androidx.compose.runtime.Stable
import androidx.compose.runtime.snapshots.Snapshot
import com.slack.circuit.backstack.BackStack.Record
import com.slack.circuit.runtime.screen.Screen
import com.slack.circuit.backstack.NavStack.Record

/**
* A caller-supplied stack of [Record]s for presentation with a `Navigator`. Iteration order is
* top-first (first element is the top of the stack).
*
* BackStack extends NavStack but is intended for backward-only navigation patterns. Implementations
* may provide no-op implementations for forward navigation methods.
*/
@Stable
public interface BackStack<R : Record> : Iterable<R> {
/** The number of records contained in this [BackStack] that will be seen by an iterator. */
public val size: Int

/** The top-most record in the [BackStack], or `null` if the [BackStack] is empty. */
public val topRecord: R?

/** The bottom-most record in the [BackStack], or `null` if the [BackStack] is empty. */
public val rootRecord: R?

/**
* Push a new [Record] onto the back stack. The new record will become the top of the stack.
*
* @param record The record to push onto the stack.
* @return If the [record] was successfully pushed onto the back stack
*/
public fun push(record: R): Boolean

/**
* Push a new [Screen] onto the back stack. This will be enveloped in a [Record] and the new
* record will become the top of the stack.
*
* @param screen The screen to push onto the stack.
* @return If the [screen] was successfully pushed onto the back stack
*/
public fun push(screen: Screen): Boolean

/**
* Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was
* successful or `null` if no entry was popped.
*/
public fun pop(): R?

/**
* Pop records off the top of the backstack until one is found that matches the given predicate.
*/
public fun popUntil(predicate: (R) -> Boolean): List<R> {
return buildList {
while (topRecord?.let(predicate) == false) {
val popped = pop() ?: break
add(popped)
}
}
}

/**
* Saves the current back stack entry list in an internal state store. It can be later restored by
* the root screen to [restoreState].
*
* This call will overwrite any existing stored state with the same root screen.
*/
public fun saveState()

/**
* Restores the saved state with the given [screen], adding it on top of the existing entry list.
* If you wish to replace the current entry list, you should [pop] all of the existing entries off
* before calling this function.
*
* @param screen The root screen which was previously saved using [saveState].
* @return Returns true if there was any back stack state to restore.
*/
public fun restoreState(screen: Screen): Boolean

/**
* Peek at the [Screen] in the internal state store that have been saved using [saveState].
*
* @return The list of [Screen]s currently in the internal state store, will be empty if there is
* no saved state.
*/
public fun peekState(): List<Screen>

/**
* Removes the state associated with the given [screen] from the internal state store.
*
* @return true if the state was removed, false if no state was found for the given screen.
*/
public fun removeState(screen: Screen): Boolean

/**
* Whether the back stack contains the given [record].
*
* @param includeSaved Whether to also check if the record is contained by any saved back stack
* state. See [saveState].
*/
public fun containsRecord(record: R, includeSaved: Boolean): Boolean

/**
* Whether a record with the given [key] is reachable within the back stack or saved state.
* Reachable means that it is either currently in the visible back stack or if we popped `depth`
* times, it would be found.
*
* @param key The record's key to look for.
* @param depth How many records to consider popping from the top of the stack before considering
* the key unreachable. A depth of zero means only check the current visible stack. A depth of 1
* means check the current visible stack plus one record popped off the top, and so on.
* @param includeSaved Whether to also check if the record is contained by any saved back stack
* state. See [saveState].
*/
public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean

@Stable
public interface Record {
/**
* A value that identifies this record uniquely, even if it shares the same [screen] with
* another record. This key may be used by [BackStackRecordLocalProvider]s to associate
* presentation data with a record across composition recreation.
*
* [key] MUST NOT change for the life of the record.
*/
public val key: String

/** The [Screen] that should present this record. */
public val screen: Screen
}
}

/** `true` if the [BackStack] contains no records. [Iterable.firstOrNull] will return `null`. */
public val BackStack<out Record>.isEmpty: Boolean
get() = size == 0

/** `true` if the [BackStack] contains exactly one record. */
public val BackStack<out Record>.isAtRoot: Boolean
get() = size == 1
public interface BackStack<R : Record> : NavStack<R>, Iterable<R> {

/** Clear any saved state from the [BackStack]. */
public fun BackStack<out Record>.clearState() {
Snapshot.withMutableSnapshot {
for (screen in peekState()) {
removeState(screen)
}
}
@Stable public interface Record : NavStack.Record
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package com.slack.circuit.backstack

import androidx.compose.runtime.Stable
import androidx.compose.runtime.snapshots.Snapshot
import com.slack.circuit.backstack.NavStack.Record
import com.slack.circuit.runtime.NavStackList
import com.slack.circuit.runtime.screen.Screen

/**
* A navigation stack supporting bidirectional navigation with browser-style forward/backward
* traversal.
*
* Manages [Record]s in a list with position tracking, enabling navigation without modifying the
* stack structure. Key positions are [topRecord] (newest), [currentRecord] (active), and
* [rootRecord] (oldest).
*
* Supports multiple independent nav stacks (e.g., bottom nav tabs) via [saveState], [restoreState],
* [peekState], and [removeState]. State is keyed by root screen.
*
* @see SaveableNavStack for the primary implementation
* @see NavStackList for immutable snapshots
*/
@Stable
public interface NavStack<R : Record> {
/** The number of records in the stack. */
public val size: Int

/** The top-most (newest) record, or null if empty. Always the most recently added record. */
public val topRecord: R?

/**
* The currently active record, or null if empty. May differ from [topRecord] when navigated
* backward.
*/
public val currentRecord: R?

/** The bottom-most (oldest) record, or null if empty. Typically the initial root screen. */
public val rootRecord: R?

/**
* Adds a screen to the stack. Truncates forward history if not at top.
*
* @return true if added, false otherwise
*/
public fun push(screen: Screen): Boolean

/**
* Adds a record to the stack. Truncates forward history if not at top.
*
* @return true if added, false otherwise
*/
public fun push(record: R): Boolean

/**
* Removes and returns the current record, truncating forward history.
*
* @return The removed record, or null if empty
*/
public fun pop(): R?

/**
* Pops records until one matches the predicate.
*
* @return List of popped records
*/
public fun popUntil(predicate: (R) -> Boolean): List<R> {
return buildList {
while (topRecord?.let(predicate) == false) {
val popped = pop() ?: break
add(popped)
}
}
}

/**
* Move forward in navigation history towards the [topRecord].
*
* @return true if moved, false otherwise.
*/
public fun forward(): Boolean

/**
* Move backward in navigation history towards the [rootRecord].
*
* @return true if moved, false otherwise.
*/
public fun backward(): Boolean

/**
* Creates an immutable snapshot of the current stack state.
*
* @return [NavStackList] of current state, or null if empty.
*/
public fun snapshot(): NavStackList<R>?

/** Saves the current stack to an internal store, keyed by the root screen. */
public fun saveState()

/**
* Restores previously saved state for the given root [screen], replacing the current stack.
*
* @return true if state was restored, false if no saved state found
*/
public fun restoreState(screen: Screen): Boolean

/**
* Returns list of root screens that have saved state.
*
* @return List of screens with saved state, empty if none.
*/
public fun peekState(): List<Screen>

/**
* Removes saved state for the given [screen].
*
* @return true if state was removed, false otherwise.
*/
public fun removeState(screen: Screen): Boolean

/**
* Checks if the stack contains the given [record].
*
* @param includeSaved Whether to also check saved stack states
*/
public fun containsRecord(record: R, includeSaved: Boolean): Boolean

/**
* Checks if a record with the given [key] is reachable within [depth] pops from current position.
*
* @param key The record key to find
* @param depth Depth to search (0 = the current record, 1 = single record before and after)
* @param includeSaved Whether to also check saved states
*/
public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean

/**
* A record in the navigation stack, wrapping a [Screen] with a unique identity.
*
* Each record has a stable [key] for identity tracking across configuration changes and state
* restoration.
*/
@Stable
public interface Record {
/**
* Unique identifier for this record. Remains stable across configuration changes and must not
* change for the life of the record. Used to associate retained and saved data with records.
*/
public val key: String

/** The [Screen] that this record presents. */
public val screen: Screen
}
}

/** The screen of the current record, or null if empty. */
public val NavStack<out Record>.currentScreen: Screen?
get() = currentRecord?.screen

/** True if the stack is empty. */
public val NavStack<out Record>.isEmpty: Boolean
get() = size == 0

/** The index of the last record in the stack. */
public val NavStack<out Record>.lastIndex: Int
get() = size - 1

/** True if the current position is at the root. */
public val NavStack<out Record>.isAtRoot: Boolean
get() = currentRecord == rootRecord

/** True if the current position is at the top. */
public val NavStack<out Record>.isAtTop: Boolean
get() = currentRecord == topRecord

/** True if we can navigate backwards (not at root). */
public val NavStack<out Record>.canGoBack: Boolean
get() = currentRecord != rootRecord

/** True if we can navigate forwards (not at top). */
public val NavStack<out Record>.canGoForward: Boolean
get() = currentRecord != topRecord

/** Clears all saved state from the stack. */
public fun NavStack<out Record>.clearState() {
Snapshot.withMutableSnapshot {
for (screen in peekState()) {
removeState(screen)
}
}
}
Loading