|
12 | 12 | package gg.essential.gui.elementa.state.v2 |
13 | 13 |
|
14 | 14 | import gg.essential.elementa.state.v2.ReferenceHolder |
| 15 | +import kotlinx.coroutines.CoroutineScope |
| 16 | +import kotlinx.coroutines.CoroutineStart |
| 17 | +import kotlinx.coroutines.Job |
| 18 | +import kotlinx.coroutines.launch |
15 | 19 | import kotlinx.coroutines.suspendCancellableCoroutine |
16 | 20 | import kotlin.coroutines.resume |
17 | 21 |
|
@@ -107,3 +111,54 @@ interface StateIterator<T> { |
107 | 111 | suspend operator fun hasNext(): Boolean |
108 | 112 | operator fun next(): T |
109 | 113 | } |
| 114 | + |
| 115 | +/** |
| 116 | + * Returns a new [State] which contains result of applying the given suspending [block] to the value of `this` [State]. |
| 117 | + * The returned [State] will return `null` while the function is suspended. |
| 118 | + * When the value of `this` [State] changes, the suspending function is cancelled and a new one is launched. |
| 119 | + * |
| 120 | + * If [previousWhileWorking] is `true` and the value of `this` [State] changes, the returned [State] will continue to |
| 121 | + * provide the latest result (if any) instead of returning `null`. Consequently, if the given [block] never returns |
| 122 | + * `null`, the returned [State] will only ever be `null` during the initial load. |
| 123 | + * |
| 124 | + * If the given [block] returns a result without suspending, the returned [State] is guaranteed to immediately return |
| 125 | + * this value, and does not suffer from the "Recursion" issue described in [effect]'s KDocs. |
| 126 | + * Note that to this end, the [block] is launched on the given [scope] with [CoroutineStart.UNDISPATCHED], so the |
| 127 | + * dispatcher on this [scope] will not be respected until the first suspension point. However, given [State] is not |
| 128 | + * generally thread-safe, the given dispatcher must dispatch to the same thread as the thread currently evaluating the |
| 129 | + * State anyway, so this should not be an issue in practice. |
| 130 | + */ |
| 131 | +fun <I, R> State<I>.asyncMap(scope: CoroutineScope, previousWhileWorking: Boolean = true, block: suspend (I) -> R): State<R?> { |
| 132 | + val prevResultState = mutableStateOf<Pair<I, R>?>(null) |
| 133 | + var jobInput: I? = null |
| 134 | + var job: Job? = null |
| 135 | + return State { |
| 136 | + val input = this@asyncMap() |
| 137 | + |
| 138 | + // If we have a previous result and the input is unchanged, we can just use that |
| 139 | + val prevResult = prevResultState() |
| 140 | + if (prevResult != null && prevResult.first == input) { |
| 141 | + return@State prevResult.second |
| 142 | + } |
| 143 | + |
| 144 | + // Otherwise we need to go compute the result for the new input, unless we're already doing that of course |
| 145 | + if (jobInput != input) { |
| 146 | + jobInput = input |
| 147 | + job?.cancel() |
| 148 | + job = scope.launch(start = CoroutineStart.UNDISPATCHED) { |
| 149 | + val result = block(input) |
| 150 | + prevResultState.set(Pair(input, result)) |
| 151 | + } |
| 152 | + |
| 153 | + // If the block returned a result without ever suspending, then we can immediately make use of that |
| 154 | + // result. |
| 155 | + val latestResult = prevResultState() |
| 156 | + if (latestResult != null && latestResult.first == input) { |
| 157 | + return@State latestResult.second |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + // Computation in progress, best we can do is provide the previous value if that's acceptable |
| 162 | + return@State if (previousWhileWorking) prevResult?.second else null |
| 163 | + } |
| 164 | +} |
0 commit comments