Skip to content

Instantly share code, notes, and snippets.

@zach-klippenstein
Created June 25, 2024 23:38
Show Gist options
  • Save zach-klippenstein/ca1679a35594be9c6b8ac3493764d031 to your computer and use it in GitHub Desktop.
Save zach-klippenstein/ca1679a35594be9c6b8ac3493764d031 to your computer and use it in GitHub Desktop.
A sketch of a Compose MutableState object that only accepts writes that happen after a certain time after the previously-accepted write. Writes that happen too quickly are ignored. Inspired by https://x.com/pablisc0/status/1793599496305483881
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SnapshotMutationPolicy
import androidx.compose.runtime.structuralEqualityPolicy
/**
* Returns a [MutableState] object that only accepts writes that happen [debounceMillis] after the
* previously-accepted write. Writes that happen less than [debounceMillis] after a previous write
* are ignored.
*
* Write times are determined using [System.currentTimeMillis].
*
* When the value is written in multiple concurrent snapshots and a conflict occurs when applying,
* the timestamp of the merged result will be the timestamp of the _later_ write, unless the values
* are the same, in which case it will be the timestamp of the earlier write.
*/
fun <T> throttlingMutableStateOf(
initialValue: T,
debounceMillis: Long = 500L,
mutationPolicy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = ThrottlingMutableState(initialValue, debounceMillis, mutationPolicy)
private class ThrottlingMutableState<T>(
initialValue: T,
private val debounceMillis: Long,
private val mutationPolicy: SnapshotMutationPolicy<T>
) : MutableState<T>, StateObject {
private var record = ThrottledRecord(initialValue)
override var value: T
get() = record.readable(this).value
set(value) {
val now = System.currentTimeMillis()
// withCurrent: Don't notify write observers unless we actually update something.
record.withCurrent {
if (
// Timestamp comparison is constant time, so do it first.
now - it.lastWriteMillis > debounceMillis &&
!mutationPolicy.equivalent(value, it.value)
) {
record.writable(this) {
it.value = value
it.lastWriteMillis = now
}
}
}
}
override val firstStateRecord: StateRecord
get() = record
override fun prependStateRecord(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
record = value as ThrottledRecord<T>
}
override fun component1(): T = value
override fun component2(): (T) -> Unit = { value = it }
@Suppress("UNCHECKED_CAST")
override fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? {
val previousRecord = previous as ThrottledRecord<T>
val currentRecord = current as ThrottledRecord<T>
val appliedRecord = applied as ThrottledRecord<T>
if (mutationPolicy.equivalent(currentRecord.value, appliedRecord.value)) {
// Always resolve merge with the earlier timestamp, since if the state were updated
// twice to the same value in the same snapshot, only the earlier timestamp would be
// recorded.
return (appliedRecord.create() as ThrottledRecord<T>).also {
it.value = appliedRecord.value
it.lastWriteMillis =
minOf(currentRecord.lastWriteMillis, appliedRecord.lastWriteMillis)
}
}
val merged = mutationPolicy.merge(
previous = previousRecord.value,
current = currentRecord.value,
applied = appliedRecord.value
) ?: return null
// The timestamp of the merge is that of the latest record to be written, since the new
// value represents the latest write that explicitly occurred. It should not be the time of
// the actual merge since that's just the time the snapshot was applied, which does not
// correspond to any explicit write.
return (appliedRecord.create() as ThrottledRecord<T>).also {
it.value = merged
it.lastWriteMillis =
maxOf(currentRecord.lastWriteMillis, appliedRecord.lastWriteMillis)
}
}
private class ThrottledRecord<T>(var value: T) : StateRecord() {
var lastWriteMillis = -1L
override fun create(): StateRecord = ThrottledRecord(value)
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
value as ThrottledRecord<T>
this.value = value.value
this.lastWriteMillis = value.lastWriteMillis
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment