1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.spatial
18
19 import androidx.collection.MutableIntObjectMap
20 import androidx.collection.mutableIntObjectMapOf
21 import androidx.compose.ui.graphics.Matrix
22 import androidx.compose.ui.node.DelegatableNode
23 import androidx.compose.ui.node.DelegatableNode.RegistrationHandle
24 import androidx.compose.ui.node.Nodes
25 import androidx.compose.ui.node.requireCoordinator
26 import androidx.compose.ui.node.requireLayoutNode
27 import androidx.compose.ui.unit.IntOffset
28 import androidx.compose.ui.unit.round
29 import kotlin.math.min
30
31 internal class ThrottledCallbacks {
32
33 /**
34 * Entry for a throttled callback for [RelativeLayoutBounds] associated to the given [node].
35 *
36 * Supports a linked-list structure for multiple callbacks on the same [node] through [next].
37 */
38 inner class Entry(
39 val id: Int,
40 val throttleMillis: Long,
41 val debounceMillis: Long,
42 val node: DelegatableNode,
43 val callback: (RelativeLayoutBounds) -> Unit,
44 ) : RegistrationHandle {
45
46 var next: Entry? = null
47
48 var topLeft: Long = 0
49 var bottomRight: Long = 0
50 var lastInvokeMillis: Long = -throttleMillis
51 var lastUninvokedFireMillis: Long = -1
52
53 override fun unregister() {
54 val result = rectChangedMap.multiRemove(id, this)
55 if (!result) removeFromGlobalEntries(this)
56 }
57
58 fun fire(
59 topLeft: Long,
60 bottomRight: Long,
61 windowOffset: IntOffset,
62 screenOffset: IntOffset,
63 viewToWindowMatrix: Matrix?,
64 ) {
65 val rect =
66 rectInfoFor(
67 node = node,
68 topLeft = topLeft,
69 bottomRight = bottomRight,
70 windowOffset = windowOffset,
71 screenOffset = screenOffset,
72 viewToWindowMatrix = viewToWindowMatrix,
73 )
74 if (rect == null) {
75 return
76 }
77
78 callback(rect)
79 }
80 }
81
82 /** Set of callbacks for onRectChanged. */
83 val rectChangedMap = mutableIntObjectMapOf<Entry>()
84
85 /**
86 * Set of callbacks for onGlobalLayoutListener given as a Linked List using [Entry].
87 *
88 * These are expected to be fired after any Rect or Window/Screen change.
89 */
90 var globalChangeEntries: Entry? = null
91
92 // We can use this to schedule a "triggerDebounced" call. If it is -1, then nothing
93 // needs to be scheduled.
94 var minDebounceDeadline: Long = -1
95 var windowOffset: IntOffset = IntOffset.Zero
96 var screenOffset: IntOffset = IntOffset.Zero
97 var viewToWindowMatrix: Matrix? = null
98
99 fun updateOffsets(screen: IntOffset, window: IntOffset, matrix: Matrix?): Boolean {
100 var updated = false
101 if (window != windowOffset) {
102 windowOffset = window
103 updated = true
104 }
105 if (screen != screenOffset) {
106 screenOffset = screen
107 updated = true
108 }
109 if (matrix != null) {
110 viewToWindowMatrix = matrix
111 updated = true
112 }
113 return updated
114 }
115
116 private fun roundDownToMultipleOf8(x: Long): Long {
117 return (x shr 3) shl 3
118 }
119
120 fun registerOnRectChanged(
121 id: Int,
122 throttleMillis: Long,
123 debounceMillis: Long,
124 node: DelegatableNode,
125 callback: (RelativeLayoutBounds) -> Unit,
126 ): RegistrationHandle {
127 // If zero is set for debounce, we use throttle in its place. This guarantees that
128 // consumers will get the value where the node "settled".
129 val debounceToUse = if (debounceMillis == 0L) throttleMillis else debounceMillis
130
131 return rectChangedMap.multiPut(
132 key = id,
133 value =
134 Entry(
135 id = id,
136 throttleMillis = throttleMillis,
137 debounceMillis = debounceToUse,
138 node = node,
139 callback = callback,
140 )
141 )
142 }
143
144 fun registerOnGlobalChange(
145 id: Int,
146 throttleMillis: Long,
147 debounceMillis: Long,
148 node: DelegatableNode,
149 callback: (RelativeLayoutBounds) -> Unit,
150 ): RegistrationHandle {
151 // If zero is set for debounce, we use throttle in its place. This guarantees that
152 // consumers will get the value where the node "settled".
153 val debounceToUse = if (debounceMillis == 0L) throttleMillis else debounceMillis
154
155 val entry =
156 Entry(
157 id = id,
158 throttleMillis = throttleMillis,
159 debounceMillis = debounceToUse,
160 node = node,
161 callback = callback,
162 )
163 addToGlobalEntries(entry)
164 return entry
165 }
166
167 // We call this when a layout node with `semanticsId = id` changes it's global bounds. For
168 // throttled callbacks this may cause the callback to get invoked, for debounced nodes it
169 // updates the deadlines
170 fun fireOnUpdatedRect(id: Int, topLeft: Long, bottomRight: Long, currentMillis: Long) {
171 rectChangedMap.runFor(id) { entry ->
172 fireWithUpdatedRect(entry, topLeft, bottomRight, currentMillis)
173 }
174 }
175
176 /** Fires all [rectChangedMap] entries with latest window/screen info. */
177 fun fireOnRectChangedEntries(currentMillis: Long) {
178 val windowOffset = windowOffset
179 val screenOffset = screenOffset
180 val viewToWindowMatrix = viewToWindowMatrix
181 rectChangedMap.multiForEach { entry ->
182 fire(
183 entry = entry,
184 windowOffset = windowOffset,
185 screenOffset = screenOffset,
186 viewToWindowMatrix = viewToWindowMatrix,
187 currentMillis = currentMillis
188 )
189 }
190 }
191
192 /** Fires all [globalChangeEntries] entries with latest window/screen info. */
193 fun fireGlobalChangeEntries(currentMillis: Long) {
194 val windowOffset = windowOffset
195 val screenOffset = screenOffset
196 val viewToWindowMatrix = viewToWindowMatrix
197 globalChangeEntries?.linkedForEach { entry ->
198 val node = entry.node.requireLayoutNode()
199 val offsetFromRoot = node.offsetFromRoot
200 val lastSize = node.lastSize
201
202 // For global change callbacks, we'll still need to update the Entry bounds
203 entry.topLeft = offsetFromRoot.packedValue
204 entry.bottomRight =
205 packXY(offsetFromRoot.x + lastSize.width, offsetFromRoot.y + lastSize.height)
206
207 fire(
208 entry = entry,
209 windowOffset = windowOffset,
210 screenOffset = screenOffset,
211 viewToWindowMatrix = viewToWindowMatrix,
212 currentMillis = currentMillis
213 )
214 }
215 }
216
217 // We call this to invoke any debounced callbacks that have passed their deadline. This could
218 // be done every frame, or on some other interval. This means the precision of the debouncing
219 // is less, but it would reduce the overhead of all of this scheduling.
220 fun triggerDebounced(currentMillis: Long) {
221 if (minDebounceDeadline > currentMillis) return
222 val windowOffset = windowOffset
223 val screenOffset = screenOffset
224 val viewToWindowMatrix = viewToWindowMatrix
225 var minDeadline = Long.MAX_VALUE
226 rectChangedMap.multiForEach { entry ->
227 minDeadline =
228 debounceEntry(
229 entry = entry,
230 windowOffset = windowOffset,
231 screenOffset = screenOffset,
232 viewToWindowMatrix = viewToWindowMatrix,
233 currentMillis = currentMillis,
234 minDeadline = minDeadline
235 )
236 }
237 globalChangeEntries?.linkedForEach { entry ->
238 minDeadline =
239 debounceEntry(
240 entry = entry,
241 windowOffset = windowOffset,
242 screenOffset = screenOffset,
243 viewToWindowMatrix = viewToWindowMatrix,
244 currentMillis = currentMillis,
245 minDeadline = minDeadline
246 )
247 }
248 minDebounceDeadline = if (minDeadline == Long.MAX_VALUE) -1 else minDeadline
249 }
250
251 private fun fireWithUpdatedRect(
252 entry: Entry,
253 topLeft: Long,
254 bottomRight: Long,
255 currentMillis: Long
256 ) {
257 val lastInvokeMillis = entry.lastInvokeMillis
258 val throttleMillis = entry.throttleMillis
259 val debounceMillis = entry.debounceMillis
260 val pastThrottleDeadline = currentMillis - lastInvokeMillis >= throttleMillis
261 val zeroDebounce = debounceMillis == 0L
262 val zeroThrottle = throttleMillis == 0L
263
264 entry.topLeft = topLeft
265 entry.bottomRight = bottomRight
266
267 // There are essentially 3 different cases that we need to handle here:
268
269 // 1. throttle = 0, debounce = 0
270 // -> always invoke immediately
271 // 2. throttle = 0, debounce > 0
272 // -> set deadline to <debounce> milliseconds from now
273 // 3. throttle > 0, debounce > 0
274 // -> invoke if we haven't invoked for <throttle> milliseconds, otherwise, set the
275 // deadline to <debounce>
276
277 // Note that the `throttle > 0, debounce = 0` case is not possible, since we use the
278 // throttle value as a debounce value in that case.
279
280 val canInvoke = (!zeroDebounce && !zeroThrottle) || zeroDebounce
281
282 if (pastThrottleDeadline && canInvoke) {
283 entry.lastUninvokedFireMillis = -1
284 entry.lastInvokeMillis = currentMillis
285 entry.fire(topLeft, bottomRight, windowOffset, screenOffset, viewToWindowMatrix)
286 } else if (!zeroDebounce) {
287 entry.lastUninvokedFireMillis = currentMillis
288 val currentMinDeadline = minDebounceDeadline
289 val thisDeadline = currentMillis + debounceMillis
290 if (currentMinDeadline > 0 && thisDeadline < currentMinDeadline) {
291 minDebounceDeadline = currentMinDeadline
292 }
293 }
294 }
295
296 private fun fire(
297 entry: Entry,
298 windowOffset: IntOffset,
299 screenOffset: IntOffset,
300 viewToWindowMatrix: Matrix?,
301 currentMillis: Long,
302 ) {
303 val lastInvokeMillis = entry.lastInvokeMillis
304 val throttleOkay = currentMillis - lastInvokeMillis > entry.throttleMillis
305 val debounceOkay = entry.debounceMillis == 0L
306 entry.lastUninvokedFireMillis = currentMillis
307 if (throttleOkay && debounceOkay) {
308 entry.lastInvokeMillis = currentMillis
309 entry.fire(
310 entry.topLeft,
311 entry.bottomRight,
312 windowOffset,
313 screenOffset,
314 viewToWindowMatrix
315 )
316 }
317 if (!debounceOkay) {
318 val currentMinDeadline = minDebounceDeadline
319 val thisDeadline = currentMillis + entry.debounceMillis
320 if (currentMinDeadline > 0 && thisDeadline < currentMinDeadline) {
321 minDebounceDeadline = currentMinDeadline
322 }
323 }
324 }
325
326 /** @return updated minDeadline */
327 private fun debounceEntry(
328 entry: Entry,
329 windowOffset: IntOffset,
330 screenOffset: IntOffset,
331 viewToWindowMatrix: Matrix?,
332 currentMillis: Long,
333 minDeadline: Long
334 ): Long {
335 var newMinDeadline = minDeadline
336 if (entry.debounceMillis > 0 && entry.lastUninvokedFireMillis > 0) {
337 if (currentMillis - entry.lastUninvokedFireMillis > entry.debounceMillis) {
338 entry.lastInvokeMillis = currentMillis
339 entry.lastUninvokedFireMillis = -1
340 val topLeft = entry.topLeft
341 val bottomRight = entry.bottomRight
342 entry.fire(topLeft, bottomRight, windowOffset, screenOffset, viewToWindowMatrix)
343 } else {
344 newMinDeadline =
345 min(newMinDeadline, entry.lastUninvokedFireMillis + entry.debounceMillis)
346 }
347 }
348 return newMinDeadline
349 }
350
351 private fun addToGlobalEntries(entry: Entry) {
352 // For global entries, we can append the new entry to the start
353 val oldInitialEntry = globalChangeEntries
354 entry.next = oldInitialEntry
355 globalChangeEntries = entry
356 }
357
358 /**
359 * Removes [entry] from the LinkedList in [globalChangeEntries].
360 *
361 * @return Whether the given [entry] was found & removed from [globalChangeEntries].
362 */
363 private fun removeFromGlobalEntries(entry: Entry): Boolean {
364 val initialGlobalEntry = globalChangeEntries
365 if (initialGlobalEntry === entry) {
366 globalChangeEntries = initialGlobalEntry.next
367 entry.next = null
368 return true
369 }
370 var last = initialGlobalEntry
371 var node = last?.next
372 while (node != null) {
373 if (node === entry) {
374 last?.next = node.next
375 entry.next = null
376 return true
377 }
378 last = node
379 node = node.next
380 }
381 return false
382 }
383
384 /** Calls [block] for every [Entry] reachable from the given node through [Entry.next]. */
385 private inline fun Entry.linkedForEach(block: (Entry) -> Unit) {
386 var node: Entry? = this
387 while (node != null) {
388 block(node)
389 node = node.next
390 }
391 }
392
393 private inline fun MutableIntObjectMap<Entry>.multiForEach(block: (Entry) -> Unit) {
394 forEachValue { it ->
395 var entry: Entry? = it
396 while (entry != null) {
397 block(entry)
398 entry = entry.next
399 }
400 }
401 }
402
403 private inline fun MutableIntObjectMap<Entry>.runFor(id: Int, block: (Entry) -> Unit) {
404 var entry: Entry? = get(id)
405 while (entry != null) {
406 block(entry)
407 entry = entry.next
408 }
409 }
410
411 private fun MutableIntObjectMap<Entry>.multiPut(key: Int, value: Entry): Entry {
412 var entry: Entry = getOrPut(key) { value }
413 if (entry !== value) {
414 while (entry.next != null) {
415 entry = entry.next!!
416 }
417 entry.next = value
418 }
419 return value
420 }
421
422 private fun MutableIntObjectMap<Entry>.multiRemove(key: Int, value: Entry): Boolean {
423 return when (val result = remove(key)) {
424 null -> false
425 value -> {
426 val next = value.next
427 value.next = null
428 if (next != null) {
429 put(key, next)
430 }
431 true
432 }
433 else -> {
434 put(key, result)
435 var entry = result
436 while (entry != null) {
437 val next = entry.next ?: return false
438 if (next === value) {
439 entry.next = value.next
440 value.next = null
441 break
442 }
443 entry = entry.next
444 }
445 true
446 }
447 }
448 }
449 }
450
rectInfoFornull451 internal fun rectInfoFor(
452 node: DelegatableNode,
453 topLeft: Long,
454 bottomRight: Long,
455 windowOffset: IntOffset,
456 screenOffset: IntOffset,
457 viewToWindowMatrix: Matrix?,
458 ): RelativeLayoutBounds? {
459 val coordinator = node.requireCoordinator(Nodes.Layout)
460 val layoutNode = node.requireLayoutNode()
461 if (!layoutNode.isPlaced) return null
462 // this is the outer-rect of the layout node. we may need to transform this
463 // rectangle to be accurate up to the modifier node requesting the callback. Most
464 // of the time this will be the outer-most rectangle, so no transformation will be
465 // needed, and we should optimize for that fact, but we need to make sure that it
466 // is accurate.
467 val needsTransform = layoutNode.outerCoordinator !== coordinator
468 return if (needsTransform) {
469 val transformed = layoutNode.outerCoordinator.coordinates.localBoundingBoxOf(coordinator)
470 RelativeLayoutBounds(
471 transformed.topLeft.round().packedValue,
472 transformed.bottomRight.round().packedValue,
473 windowOffset,
474 screenOffset,
475 viewToWindowMatrix,
476 node,
477 )
478 } else
479 RelativeLayoutBounds(
480 topLeft,
481 bottomRight,
482 windowOffset,
483 screenOffset,
484 viewToWindowMatrix,
485 node,
486 )
487 }
488