MutatorMutex
class MutatorMutex
Mutual exclusion for UI state mutation over time.
mutate
permits interruptible state mutation over time using a standard MutatePriority
. A
MutatorMutex
enforces that only a single writer can be active at a time for a particular state
resource. Instead of queueing callers that would acquire the lock like a traditional Mutex
, new
attempts to mutate
the guarded state will either cancel the current mutator or if the current
mutator has a higher priority, the new caller will throw CancellationException
.
MutatorMutex
should be used for implementing hoisted state objects that many mutators may want
to manipulate over time such that those mutators can coordinate with one another. The
MutatorMutex
instance should be hidden as an implementation detail. For example:
Functions
suspend fun <R> mutate(
priority: MutatePriority = MutatePriority.Default,
block: suspend () -> R,
) = coroutineScope {
val mutator = Mutator(priority, coroutineContext[Job]!!)
tryMutateOrCancel(mutator)
mutex.withLock {
try {
block()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
Enforce that only a single caller may be active at a time.
If mutate
is called while another call to mutate
or mutateWith
is in progress, their
priority
values are compared. If the new caller has a priority
equal to or higher than
the call in progress, the call in progress will be cancelled, throwing
CancellationException
and the new caller's block
will be invoked. If the call in progress
had a higher priority
than the new caller, the new caller will throw
CancellationException
without invoking block
.
Parameters
priority | the priority of this mutation; MutatePriority.Default by default. Higher priority mutations will interrupt lower priority mutations. |
block | mutation code to run mutually exclusive with any other call to mutate or mutateWith . |
suspend fun <T, R> mutateWith(
receiver: T,
priority: MutatePriority = MutatePriority.Default,
block: suspend T.() -> R,
) = coroutineScope {
val mutator = Mutator(priority, coroutineContext[Job]!!)
tryMutateOrCancel(mutator)
mutex.withLock {
try {
receiver.block()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
Enforce that only a single caller may be active at a time.
If mutateWith
is called while another call to mutate
or mutateWith
is in progress,
their priority
values are compared. If the new caller has a priority
equal to or higher
than the call in progress, the call in progress will be cancelled, throwing
CancellationException
and the new caller's block
will be invoked. If the call in progress
had a higher priority
than the new caller, the new caller will throw
CancellationException
without invoking block
.
This variant of mutate
calls its block
with a receiver
, removing the need to create an
additional capturing lambda to invoke it with a receiver object. This can be used to expose a
mutable scope to the provided block
while leaving the rest of the state object read-only.
For example:
Parameters
receiver | the receiver this that block will be called with |
priority | the priority of this mutation; MutatePriority.Default by default. Higher priority mutations will interrupt lower priority mutations. |
block | mutation code to run mutually exclusive with any other call to mutate or mutateWith . |
inline fun tryMutate(block: () -> Unit): Boolean
Attempt to mutate synchronously if there is no other active caller. If there is no other
active caller, the block
will be executed in a lock. If there is another active caller,
this method will return false, indicating that the active caller needs to be cancelled
through a mutate
or mutateWith
call with an equal or higher mutation priority.
Calls to mutate
and mutateWith
will suspend until execution of the block
has finished.
Parameters
block | mutation code to run mutually exclusive with any other call to mutate , mutateWith or tryMutate . |
Returns
true if the block was executed, false if there was another active caller and the block was not executed. |
Code Examples
mutatorMutexStateObject
fun mutatorMutexStateObject() {
@Stable
class ScrollState(position: Int = 0) {
private var _position by mutableStateOf(position)
var position: Int
get() = _position.coerceAtMost(range)
set(value) {
_position = value.coerceIn(0, range)
}
private var _range by mutableStateOf(0)
var range: Int
get() = _range
set(value) {
_range = value.coerceAtLeast(0)
}
var isScrolling by mutableStateOf(false)
private set
private val mutatorMutex = MutatorMutex()
/** Only one caller to [scroll] can be in progress at a time. */
suspend fun <R> scroll(block: suspend () -> R): R =
mutatorMutex.mutate {
isScrolling = true
try {
block()
} finally {
// MutatorMutex.mutate ensures mutual exclusion between blocks.
// By setting back to false in the finally block inside mutate, we ensure that
// we
// reset the state upon cancellation before the next block starts to run (if
// any).
isScrolling = false
}
}
}
/** Arbitrary animations can be defined as extensions using only public API */
suspend fun ScrollState.animateTo(target: Int) {
scroll { animate(from = position, to = target) { newPosition -> position = newPosition } }
}
/**
* Presents two buttons for animating a scroll to the beginning or end of content. Pressing one
* will cancel any current animation in progress.
*/
@Composable
fun ScrollControls(scrollState: ScrollState) {
Row {
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { scrollState.animateTo(0) } }) {
Text("Scroll to beginning")
}
Button(onClick = { scope.launch { scrollState.animateTo(scrollState.range) } }) {
Text("Scroll to end")
}
}
}
}