feature: Multi-stack navigation tabs

This commit is contained in:
Artem Chepurnyi 2024-06-30 09:40:20 +03:00
parent 2481122cf1
commit d53d75550b
6 changed files with 197 additions and 77 deletions

View File

@ -99,6 +99,7 @@ import com.artemchep.keyguard.feature.navigation.NavigationEntry
import com.artemchep.keyguard.feature.navigation.NavigationIntent import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.NavigationNode import com.artemchep.keyguard.feature.navigation.NavigationNode
import com.artemchep.keyguard.feature.navigation.NavigationRouter import com.artemchep.keyguard.feature.navigation.NavigationRouter
import com.artemchep.keyguard.feature.navigation.NavigationStack
import com.artemchep.keyguard.feature.navigation.Route import com.artemchep.keyguard.feature.navigation.Route
import com.artemchep.keyguard.feature.send.SendRoute import com.artemchep.keyguard.feature.send.SendRoute
import com.artemchep.keyguard.feature.sync.SyncRoute import com.artemchep.keyguard.feature.sync.SyncRoute
@ -876,35 +877,14 @@ private fun navigateOnClick(
route: Route, route: Route,
) { ) {
val intent = NavigationIntent.Manual { factory -> val intent = NavigationIntent.Manual { factory ->
kotlin.run { val navStack = getStack(
val index = this.backStack.indexOfFirst { it.route === route } id = NavigationStack.createIdSuffix(route),
if (index != -1) { )
val end = this.backStack val navEntries = navStack.entries.ifEmpty {
.drop(index + 1) val entry = factory(route)
.indexOfFirst { entry -> persistentListOf(entry)
homeRoutes.any { it === entry.route }
}
// Check if that's the end of list
// or a middle part.
.let {
if (it != -1) {
index + it + 1
} else {
this.backStack.size
}
}
val left = this.backStack.subList(0, index)
val center = this.backStack.subList(index, end)
val right = this.backStack.subList(end, this.backStack.size)
return@Manual left.toPersistentList()
.addAll(right)
.addAll(center)
}
} }
navStack.copy(entries = navEntries)
val entry = factory(route)
this.backStack.add(entry)
} }
controller.queue(intent) controller.queue(intent)
} }

View File

@ -0,0 +1,8 @@
package com.artemchep.keyguard.feature.navigation
import kotlinx.collections.immutable.PersistentList
data class NavigationBackStack(
val id: String,
val entries: PersistentList<NavigationEntry>,
)

View File

@ -35,7 +35,7 @@ interface NavigationEntry : BackPressInterceptorHost {
val activeBackPressInterceptorsStateFlow: StateFlow<ImmutableMap<String, BackPressInterceptorRegistration>> val activeBackPressInterceptorsStateFlow: StateFlow<ImmutableMap<String, BackPressInterceptorRegistration>>
fun getOrCreate(id: String, create: () -> NavigationEntry): NavigationStack fun getOrCreate(id: String, create: () -> NavigationStack): NavigationPile
fun destroy() fun destroy()
} }
@ -98,12 +98,15 @@ data class NavigationEntryImpl(
} }
} }
private val subStacks = mutableMapOf<String, NavigationStack>() private val subStacks = mutableMapOf<String, NavigationPile>()
override fun getOrCreate(id: String, create: () -> NavigationEntry): NavigationStack = subStacks override fun getOrCreate(
id: String,
create: () -> NavigationStack,
): NavigationPile = subStacks
.getOrPut(id) { .getOrPut(id) {
val navEntry = create() val navStack = create()
NavigationStack(navEntry) NavigationPile(navStack)
} }
override fun destroy() { override fun destroy() {

View File

@ -97,6 +97,6 @@ sealed interface NavigationIntent {
// manual // manual
data class Manual( data class Manual(
val handle: NavigationIntentScope.((Route) -> NavigationEntry) -> PersistentList<NavigationEntry>, val handle: NavigationIntentScope.((Route) -> NavigationEntry) -> NavigationBackStack,
) : NavigationIntent ) : NavigationIntent
} }

View File

@ -1,7 +1,12 @@
package com.artemchep.keyguard.feature.navigation package com.artemchep.keyguard.feature.navigation
import kotlinx.collections.immutable.PersistentList
interface NavigationIntentScope { interface NavigationIntentScope {
val backStack: PersistentList<NavigationEntry> val backStack: NavigationBackStack
/**
* If there are multiple stacks, then find and
* return the one that has this ID, otherwise
* create a new navigation stack.
*/
fun getStack(id: String): NavigationBackStack
} }

View File

@ -27,39 +27,54 @@ fun NavigationRouter(
// destroyed. // destroyed.
val f = LocalNavigationNodeLogicalStack.current.last() val f = LocalNavigationNodeLogicalStack.current.last()
val parentScope = f.scope val parentScope = f.scope
val navStack = f.getOrCreate(id) {
NavigationEntryImpl( val navStackPrefix = id
val navPile = f.getOrCreate(id) {
val entry = NavigationEntryImpl(
source = "router root", source = "router root",
id = id, id = id,
parent = parentScope, parent = parentScope,
route = initial, route = initial,
) )
NavigationStack(
id = NavigationStack.createId(
prefix = navStackPrefix,
suffix = NavigationStack.createIdSuffix(initial),
),
entry = entry,
)
} }
val canPop = remember(navStack) {
snapshotFlow { navStack.value } val canPop = remember(navPile) {
.flatMapLatest { stack -> snapshotFlow { navPile.value }
val navEntry = stack.lastOrNull() .flatMapLatest { pile ->
if (navEntry != null) { val stack = pile.lastOrNull()
navEntry val entry = stack?.value?.lastOrNull()
if (entry != null) {
return@flatMapLatest entry
.activeBackPressInterceptorsStateFlow .activeBackPressInterceptorsStateFlow
.map { interceptors -> .map { interceptors ->
interceptors.isNotEmpty() || stack.size > 1 interceptors.isNotEmpty() || stack.value.size > 1 || pile.size > 1
} }
} else {
flowOf(false)
} }
flowOf(false)
} }
} }
NavigationController( NavigationController(
canPop = canPop, canPop = canPop,
handle = { intent -> handle = { intent ->
val backStack = navStack.value val primaryNavStack = navPile.value.lastOrNull()
?.toImmutableModel()
// Should never happen
?: return@NavigationController NavigationIntent.Pop
// If the navigation intent is a simple pop, then give it to // If the navigation intent is a simple pop, then give it to
// the back press interceptors first and only then adjust the // the back press interceptors first and only then adjust the
// navigation stack. // navigation stack.
if (intent is NavigationIntent.Pop) { if (intent is NavigationIntent.Pop) {
val backPressInterceptorRegistration = backStack val backPressInterceptorRegistration = primaryNavStack
.entries
.asReversed() .asReversed()
.firstNotNullOfOrNull { navEntry -> .firstNotNullOfOrNull { navEntry ->
val backPressInterceptors = val backPressInterceptors =
@ -73,32 +88,72 @@ fun NavigationRouter(
} }
val scope = object : NavigationIntentScope { val scope = object : NavigationIntentScope {
override val backStack: PersistentList<NavigationEntry> = backStack override val backStack: NavigationBackStack = primaryNavStack
override fun getStack(id: String) = kotlin.run {
val navStackId = NavigationStack.createId(
prefix = navStackPrefix,
suffix = id,
)
val navStack = navPile.value.firstOrNull { it.id == navStackId }
?: return@run NavigationBackStack(
id = navStackId,
entries = persistentListOf(),
)
navStack.toImmutableModel()
}
} }
val newBackStack = scope
val newNavStack = scope
.exec( .exec(
intent = intent, intent = intent,
scope = parentScope, scope = parentScope,
) )
when { if (newNavStack == null) {
newBackStack == null -> { // The intent was not handled, pass it to the next
// The intent was not handled, pass it to the next // navigation controller.
// navigation controller. return@NavigationController intent
return@NavigationController intent }
// Reorder navigation pile
val newNavPile = run {
// Modify existing navigation stack, this is
// needed to maintain the lifecycle of the
// navigation entries.
val existingNavStack = run {
navPile.value.firstOrNull { it.id == newNavStack.id }
?.apply {
// Change the values of the existing stack
value = newNavStack.entries
}
?: NavigationStack(
id = newNavStack.id,
entries = newNavStack.entries,
)
} }
newBackStack.isEmpty() -> { navPile.value
.removeAll { it.id == newNavStack.id }
.add(existingNavStack)
.removeAll { it.value.isEmpty() }
}
when {
newNavPile.isEmpty() -> {
NavigationIntent.Pop NavigationIntent.Pop
} }
else -> { else -> {
navStack.value = newBackStack navPile.value = newNavPile
// We have handled the navigation intent. // We have handled the navigation intent.
null null
} }
} }
}, },
) { controller -> ) { controller ->
val navStack = navPile.value.lastOrNull()
?: return@NavigationController
val localBackStack = navStack.value val localBackStack = navStack.value
val globalBackStack = LocalNavigationNodeLogicalStack.current.addAll(localBackStack) val globalBackStack = LocalNavigationNodeLogicalStack.current.addAll(localBackStack)
val backHandler = LocalNavigationBackHandler.current val backHandler = LocalNavigationBackHandler.current
@ -122,14 +177,50 @@ fun NavigationRouter(
} }
} }
class NavigationStack( class NavigationPile(
entry: NavigationEntry, stack: NavigationStack,
) { ) {
private val _state = kotlin.run { private val _state = kotlin.run {
val initialState = persistentListOf(entry) val initialState = persistentListOf(stack)
mutableStateOf(initialState) mutableStateOf(initialState)
} }
var value: PersistentList<NavigationStack>
get() = _state.value
set(value) {
val oldValue = _state.value
val removedItems = mutableListOf<NavigationStack>()
oldValue.forEach { e ->
val exists = value.any { it.id === e.id }
if (!exists) {
removedItems += e
}
}
removedItems.forEach { stack ->
stack.value.forEach { entry ->
entry.destroy()
}
}
_state.value = value
}
}
class NavigationStack(
val id: String,
entries: PersistentList<NavigationEntry>,
) {
companion object {
fun createId(
prefix: String,
suffix: String,
) = "$prefix|$suffix"
fun createIdSuffix(route: Route) = route::class.qualifiedName.orEmpty()
}
private val _state = mutableStateOf(entries)
var value: PersistentList<NavigationEntry> var value: PersistentList<NavigationEntry>
get() = _state.value get() = _state.value
set(value) { set(value) {
@ -142,17 +233,30 @@ class NavigationStack(
removedItems += e removedItems += e
} }
} }
removedItems.forEach { removedItems.forEach { entry ->
it.destroy() entry.destroy()
} }
_state.value = value _state.value = value
} }
constructor(
id: String,
entry: NavigationEntry,
) : this(
id = id,
entries = persistentListOf(entry),
)
fun toImmutableModel() = NavigationBackStack(
id = id,
entries = value,
)
} }
private fun NavigationIntentScope.exec( private fun NavigationIntentScope.exec(
intent: NavigationIntent, intent: NavigationIntent,
scope: CoroutineScope, scope: CoroutineScope,
): PersistentList<NavigationEntry>? = when (intent) { ): NavigationBackStack? = when (intent) {
is NavigationIntent.Composite -> run compose@{ is NavigationIntent.Composite -> run compose@{
var ns = this var ns = this
for (subIntent in intent.list) { for (subIntent in intent.list) {
@ -161,23 +265,27 @@ private fun NavigationIntentScope.exec(
scope = scope, scope = scope,
) ?: return@compose null ) ?: return@compose null
ns = object : NavigationIntentScope { ns = object : NavigationIntentScope {
override val backStack: PersistentList<NavigationEntry> override val backStack: NavigationBackStack
get() = new get() = new
override fun getStack(id: String) = this@exec.getStack(id)
} }
} }
ns.backStack ns.backStack
} }
is NavigationIntent.NavigateToRoute -> kotlin.run { is NavigationIntent.NavigateToRoute -> kotlin.run {
val backStack = when (intent.launchMode) { val entries = when (intent.launchMode) {
NavigationIntent.NavigateToRoute.LaunchMode.DEFAULT -> backStack NavigationIntent.NavigateToRoute.LaunchMode.DEFAULT -> backStack.entries
NavigationIntent.NavigateToRoute.LaunchMode.SINGLE -> { NavigationIntent.NavigateToRoute.LaunchMode.SINGLE -> {
val clearedBackStack = val clearedBackStack =
backStack.removeAll { it.route::class == intent.route::class } backStack.entries.removeAll { it.route::class == intent.route::class }
val existingEntry = backStack.lastOrNull { it.route == intent.route } val existingEntry = backStack.entries.lastOrNull { it.route == intent.route }
if (existingEntry != null) { if (existingEntry != null) {
// Fast path if existing route matches the new route. // Fast path if existing route matches the new route.
return@run clearedBackStack.add(existingEntry) return@run backStack.copy(
entries = clearedBackStack.add(existingEntry),
)
} }
clearedBackStack clearedBackStack
@ -191,7 +299,9 @@ private fun NavigationIntentScope.exec(
parent = scope, parent = scope,
route = r, route = r,
) )
backStack.add(e) backStack.copy(
entries = entries.add(e),
)
} }
is NavigationIntent.SetRoute -> { is NavigationIntent.SetRoute -> {
@ -202,19 +312,33 @@ private fun NavigationIntentScope.exec(
parent = scope, parent = scope,
route = r, route = r,
) )
persistentListOf(e) backStack.copy(
entries = persistentListOf(e),
)
}
is NavigationIntent.NavigateToStack -> {
backStack.copy(
entries = intent.stack.toPersistentList(),
)
} }
is NavigationIntent.NavigateToStack -> intent.stack.toPersistentList()
is NavigationIntent.Pop -> { is NavigationIntent.Pop -> {
if (backStack.size > 0) { val entries = backStack.entries
backStack.removeAt(backStack.size - 1) if (entries.size > 0) {
val newEntries = entries.removeAt(entries.size - 1)
backStack.copy(entries = newEntries)
} else { } else {
null null
} }
} }
is NavigationIntent.PopById -> backStack.popById(intent.id, intent.exclusive) is NavigationIntent.PopById -> run {
val newEntries = backStack.entries.popById(intent.id, intent.exclusive)
?: return@run null
backStack.copy(entries = newEntries)
}
is NavigationIntent.Manual -> { is NavigationIntent.Manual -> {
val factory = fun(route: Route): NavigationEntry = val factory = fun(route: Route): NavigationEntry =
NavigationEntryImpl( NavigationEntryImpl(