Created
June 25, 2024 23:38
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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