feature: Cancel the selection on back press #26

This commit is contained in:
Artem Chepurnoy 2024-01-21 19:39:04 +02:00
parent 3de0e3734e
commit b4859a19fb
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
9 changed files with 177 additions and 29 deletions

View File

@ -1,17 +1,25 @@
package com.artemchep.keyguard.feature.navigation
import androidx.compose.runtime.staticCompositionLocalOf
import com.artemchep.keyguard.feature.navigation.backpress.BackPressInterceptorHost
import com.artemchep.keyguard.feature.navigation.backpress.BackPressInterceptorRegistration
import com.artemchep.keyguard.feature.navigation.state.FlowHolderViewModel
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.util.UUID
interface NavigationEntry {
interface NavigationEntry : BackPressInterceptorHost {
companion object {
fun new() {
}
@ -24,6 +32,8 @@ interface NavigationEntry {
val scope: CoroutineScope
val vm: FlowHolderViewModel
val activeBackPressInterceptorsStateFlow: StateFlow<ImmutableMap<String, BackPressInterceptorRegistration>>
fun destroy()
}
@ -43,7 +53,14 @@ data class NavigationEntryImpl(
override val scope = parent + job
override val vm: FlowHolderViewModel = FlowHolderViewModel(source, scope)
override val vm: FlowHolderViewModel = FlowHolderViewModel(this)
private val activeBackPressInterceptorsStateSink = MutableStateFlow(
persistentMapOf<String, BackPressInterceptorRegistration>(),
)
override val activeBackPressInterceptorsStateFlow: StateFlow<ImmutableMap<String, BackPressInterceptorRegistration>>
get() = activeBackPressInterceptorsStateSink
init {
require('/' !in id)
@ -60,6 +77,24 @@ data class NavigationEntryImpl(
}
}
override fun interceptBackPress(
block: () -> Unit,
): () -> Unit {
val id = UUID.randomUUID().toString()
val entry = BackPressInterceptorRegistration(
id = id,
block = block,
)
activeBackPressInterceptorsStateSink.update { state ->
state.put(id, entry)
}
return {
activeBackPressInterceptorsStateSink.update { state ->
state.remove(id)
}
}
}
override fun destroy() {
vm.destroy()
job.cancel()

View File

@ -11,6 +11,8 @@ import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.UUID
@ -36,12 +38,40 @@ fun NavigationRouter(
}
val canPop = remember(navStack) {
snapshotFlow { navStack.value }
.map { it.size > 1 }
.flatMapLatest { stack ->
val navEntry = stack.lastOrNull()
if (navEntry != null) {
navEntry
.activeBackPressInterceptorsStateFlow
.map { interceptors ->
interceptors.isNotEmpty() || stack.size > 1
}
} else {
flowOf(false)
}
}
}
NavigationController(
canPop = canPop,
handle = { intent ->
val backStack = navStack.value
// If the navigation intent is a simple pop, then give it to
// the back press interceptors first and only then adjust the
// navigation stack.
if (intent is NavigationIntent.Pop) {
val backPressInterceptorRegistration = backStack
.asReversed()
.firstNotNullOfOrNull { navEntry ->
val backPressInterceptors = navEntry.activeBackPressInterceptorsStateFlow.value
backPressInterceptors.values.firstOrNull()
}
if (backPressInterceptorRegistration != null) {
backPressInterceptorRegistration.block()
return@NavigationController null
}
}
val newBackStack = backStack
.exec(
intent = intent,

View File

@ -0,0 +1,12 @@
package com.artemchep.keyguard.feature.navigation.backpress
interface BackPressInterceptorHost {
/**
* Invoke to add the back press interceptor. The [block] function will be
* called instead of altering the navigation stack. To remove the interceptor
* call the returned lambda.
*/
fun interceptBackPress(
block: () -> Unit,
): () -> Unit
}

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.feature.navigation.backpress
data class BackPressInterceptorRegistration(
val id: String,
val block: () -> Unit,
)

View File

@ -0,0 +1,57 @@
package com.artemchep.keyguard.feature.navigation.backpress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
fun BackPressInterceptorHost.interceptBackPress(
scope: CoroutineScope,
interceptorFlow: Flow<(() -> Unit)?>,
): () -> Unit {
lateinit var job: Job
var callback: (() -> Unit)? = null
// A registration to remove the back press interceptor
// from the navigation entry. When it's not null it means
// that the interceptor is active.
var unregister: (() -> Unit)? = null
fun setCallback(
cb: (() -> Unit)?,
) {
callback = cb
if (callback != null) {
if (unregister != null) {
// Do nothing
} else {
// Register a new back press handler,
// use the callback holder, so we do
// not have to re-register in the future.
unregister = interceptBackPress {
val cb = callback
?: return@interceptBackPress
cb.invoke()
}
}
} else {
unregister?.invoke()
unregister = null
}
}
job = interceptorFlow
.map { c ->
val newCallback = c.takeIf { !job.isCancelled }
setCallback(newCallback)
}
.launchIn(scope)
return {
job.cancel()
// Unregister the existing interceptor,
// if there's any.
unregister?.invoke()
unregister = null
}
}

View File

@ -8,18 +8,17 @@ import com.artemchep.keyguard.common.usecase.PutScreenState
import com.artemchep.keyguard.common.usecase.ShowMessage
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.feature.navigation.NavigationController
import com.artemchep.keyguard.feature.navigation.NavigationEntry
import com.artemchep.keyguard.platform.LeBundle
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.platform.leBundleOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.plus
import kotlinx.serialization.json.Json
class FlowHolderViewModel(
private val source: String,
private val scope: CoroutineScope,
private val navigationEntry: NavigationEntry,
) {
var bundle: LeBundle = leBundleOf()
@ -31,6 +30,8 @@ class FlowHolderViewModel(
val value: Any?,
)
private val scope get() = navigationEntry.scope
fun <T> getOrPut(
key: String,
c: NavigationController,
@ -51,6 +52,7 @@ class FlowHolderViewModel(
key = key,
scope = scope + job + Dispatchers.Default,
navigationController = c,
backPressInterceptorHost = navigationEntry,
showMessage = showMessage,
getScreenState = getScreenState,
putScreenState = putScreenState,

View File

@ -79,14 +79,12 @@ interface RememberStateFlowScope : RememberStateFlowScopeSub, CoroutineScope, Tr
)
/**
* Register a listener to back press. Once registered, you must
* call the callback with `true` if you can go back, `false` if that
* should be ignored.
* Register a back press interceptor. Once registered, the view model subscribes to
* the flow and if the given lambda is not empty -> invokes it on back press event.
*/
fun interceptBackPress(
isEnabledFlow: Flow<Boolean>,
callback: ((Boolean) -> Unit) -> Unit,
)
interceptorFlow: Flow<(() -> Unit)?>,
): () -> Unit
//
// Helpers

View File

@ -16,6 +16,8 @@ import com.artemchep.keyguard.feature.loading.getErrorReadableMessage
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.feature.navigation.NavigationController
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.backpress.BackPressInterceptorHost
import com.artemchep.keyguard.feature.navigation.backpress.interceptBackPress
import com.artemchep.keyguard.platform.LeBundle
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.platform.contains
@ -43,6 +45,7 @@ class RememberStateFlowScopeImpl(
private val putScreenState: PutScreenState,
private val windowCoroutineScope: WindowCoroutineScope,
private val navigationController: NavigationController,
private val backPressInterceptorHost: BackPressInterceptorHost,
private val json: Json,
private val scope: CoroutineScope,
private val screen: String,
@ -157,10 +160,11 @@ class RememberStateFlowScopeImpl(
}
override fun interceptBackPress(
isEnabledFlow: Flow<Boolean>,
callback: ((Boolean) -> Unit) -> Unit,
) {
}
interceptorFlow: Flow<(() -> Unit)?>,
) = backPressInterceptorHost.interceptBackPress(
scope = scope,
interceptorFlow = interceptorFlow,
)
override suspend fun loadDiskHandle(
key: String,

View File

@ -21,20 +21,24 @@ fun RememberStateFlowScope.selectionHandle(
setOf<String>()
}
// Intercept the back button while the
// selection set is not empty.
interceptBackPress(
// Intercept the back button while the
// selection set is not empty.
isEnabledFlow = itemIdsSink
.map { it.isNotEmpty() },
) { callback ->
val wereEmpty = itemIdsSink.value.isEmpty()
// Reset the selection on back
// button press.
itemIdsSink.value = emptySet()
// Eat the callback if it weren't empty.
callback(wereEmpty)
}
interceptorFlow = itemIdsSink
.map { it.isNotEmpty() }
.map { enabled ->
if (enabled) {
// lambda
{
// Reset the selection on back
// button press.
itemIdsSink.value = emptySet()
}
} else {
null
}
},
)
return object : SelectionHandle {
override val idsFlow: StateFlow<Set<String>> get() = itemIdsSink