Skip to content

Instantly share code, notes, and snippets.

@zach-klippenstein
Created June 25, 2024 23:38
Show Gist options
  • Select an option

  • Save zach-klippenstein/ca1679a35594be9c6b8ac3493764d031 to your computer and use it in GitHub Desktop.

Select an option

Save zach-klippenstein/ca1679a35594be9c6b8ac3493764d031 to your computer and use it in GitHub Desktop.

Revisions

  1. zach-klippenstein created this gist Jun 25, 2024.
    109 changes: 109 additions & 0 deletions ThrottledMutableState.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,109 @@
    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
    }
    }
    }