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