1 /*
2  * Copyright 2021 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 @file:JvmMultifileClass
18 @file:JvmName("BringIntoViewRequesterKt")
19 
20 package androidx.compose.foundation.relocation
21 
22 import androidx.compose.ui.Modifier
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.layout.LayoutCoordinates
25 import androidx.compose.ui.node.LayoutAwareModifierNode
26 import androidx.compose.ui.node.ModifierNodeElement
27 import androidx.compose.ui.node.requireLayoutCoordinates
28 import androidx.compose.ui.platform.InspectorInfo
29 import androidx.compose.ui.relocation.BringIntoViewModifierNode
30 import androidx.compose.ui.relocation.bringIntoView
31 import kotlin.jvm.JvmMultifileClass
32 import kotlin.jvm.JvmName
33 import kotlinx.coroutines.coroutineScope
34 import kotlinx.coroutines.launch
35 
36 /**
37  * A parent that can respond to [bringChildIntoView] requests from its children, and bring its
38  * content so that the item is visible on the screen. To apply a responder to an element, pass it to
39  * the [bringIntoViewResponder] modifier.
40  *
41  * When a component calls [BringIntoViewRequester.bringIntoView], the nearest
42  * [BringIntoViewResponder] is found, which is responsible for, in order:
43  * 1. Calculating a rectangle that its parent responder should bring into view by returning it from
44  *    [calculateRectForParent].
45  * 2. Performing any layout adjustments needed to ensure the requested rectangle is brought into
46  *    view in [bringChildIntoView].
47  *
48  * Here is a sample where a composable is brought into view:
49  *
50  * @sample androidx.compose.foundation.samples.BringIntoViewSample
51  *
52  * Here is a sample where a part of a composable is brought into view:
53  *
54  * @sample androidx.compose.foundation.samples.BringPartOfComposableIntoViewSample
55  * @see BringIntoViewRequester
56  */
57 @Deprecated(message = "Use BringIntoViewModifierNode instead")
58 interface BringIntoViewResponder {
59 
60     /**
61      * Return the rectangle in this node that should be brought into view by this node's parent, in
62      * coordinates relative to this node. If this node needs to adjust itself to bring [localRect]
63      * into view, the returned rectangle should be the destination rectangle that [localRect] will
64      * eventually occupy once this node's content is adjusted.
65      *
66      * @param localRect The rectangle that should be brought into view, relative to this node. This
67      *   will be the same rectangle passed to [bringChildIntoView].
68      * @return The rectangle in this node that should be brought into view itself, relative to this
69      *   node. If this node needs to adjust to bring [localRect] into view, the returned rectangle
70      *   should be the destination rectangle that [localRect] will eventually occupy, once the
71      *   adjusting animation is finished.
72      */
calculateRectForParentnull73     fun calculateRectForParent(localRect: Rect): Rect
74 
75     /**
76      * Bring this specified rectangle into bounds by making this parent to move or adjust its
77      * content appropriately.
78      *
79      * This method should ensure that only one call is being handled at a time. If you use Compose's
80      * `Animatable` you get this for free, since it will cancel the previous animation when a new
81      * one is started while preserving velocity.
82      *
83      * @param localRect A function returning the rectangle that should be brought into view,
84      *   relative to this node. This is the same rectangle that will have been passed to
85      *   [calculateRectForParent]. The function may return a different value over time, if the
86      *   bounds of the request change while the request is being processed. If the rectangle cannot
87      *   be calculated, e.g. because the [LayoutCoordinates] are not attached, return null.
88      */
89     suspend fun bringChildIntoView(localRect: () -> Rect?)
90 }
91 
92 /**
93  * A parent that can respond to [BringIntoViewRequester] requests from its children, and adjust
94  * itself so that the item is visible on screen. See [BringIntoViewResponder] for more details about
95  * how this mechanism works.
96  *
97  * @sample androidx.compose.foundation.samples.BringIntoViewSample
98  * @see BringIntoViewRequester
99  */
100 @Suppress("ModifierInspectorInfo")
101 @Deprecated(message = "Use BringIntoViewModifierNode instead")
102 fun Modifier.bringIntoViewResponder(
103     @Suppress("DEPRECATION") responder: BringIntoViewResponder
104 ): Modifier = this.then(BringIntoViewResponderElement(responder))
105 
106 @Suppress("DEPRECATION")
107 private class BringIntoViewResponderElement(private val responder: BringIntoViewResponder) :
108     ModifierNodeElement<BringIntoViewResponderNode>() {
109     override fun create(): BringIntoViewResponderNode = BringIntoViewResponderNode(responder)
110 
111     override fun update(node: BringIntoViewResponderNode) {
112         node.responder = responder
113     }
114 
115     override fun equals(other: Any?): Boolean {
116         return (this === other) ||
117             (other is BringIntoViewResponderElement) && (responder == other.responder)
118     }
119 
120     override fun hashCode(): Int {
121         return responder.hashCode()
122     }
123 
124     override fun InspectorInfo.inspectableProperties() {
125         name = "bringIntoViewResponder"
126         properties["responder"] = responder
127     }
128 }
129 
130 /**
131  * A modifier that holds state and modifier implementations for [bringIntoViewResponder]. It has
132  * parent access to the next [BringIntoViewModifierNode] via
133  * [androidx.compose.ui.relocation.bringIntoView] and additionally provides itself as the
134  * [BringIntoViewModifierNode] for subsequent modifiers. This class is responsible for recursively
135  * propagating requests up the responder chain.
136  */
137 internal class BringIntoViewResponderNode(
138     @Suppress("DEPRECATION") var responder: BringIntoViewResponder
139 ) : Modifier.Node(), BringIntoViewModifierNode, LayoutAwareModifierNode {
140 
141     override val shouldAutoInvalidate: Boolean = false
142 
143     // TODO(b/324613946) Get rid of this check.
144     private var hasBeenPlaced = false
145 
onPlacednull146     override fun onPlaced(coordinates: LayoutCoordinates) {
147         hasBeenPlaced = true
148     }
149 
150     /**
151      * Responds to a child's request by first converting [boundsProvider] into this node's
152      * [LayoutCoordinates] and then, concurrently, calling the [responder] and the [parent] to
153      * handle the request.
154      */
bringIntoViewnull155     override suspend fun bringIntoView(
156         childCoordinates: LayoutCoordinates,
157         boundsProvider: () -> Rect?
158     ) {
159         @Suppress("NAME_SHADOWING")
160         fun localRect(): Rect? {
161             if (!isAttached) return null
162             // Can't do any calculations before the node is initially placed.
163             if (!hasBeenPlaced) return null
164 
165             // Either coordinates can become detached at any time, so we have to check before every
166             // calculation.
167             val layoutCoordinates = requireLayoutCoordinates()
168             val childCoordinates = childCoordinates.takeIf { it.isAttached } ?: return null
169             val rect = boundsProvider() ?: return null
170             return layoutCoordinates.localRectOf(childCoordinates, rect)
171         }
172 
173         val parentRect = { localRect()?.let(responder::calculateRectForParent) }
174 
175         coroutineScope {
176             // For the item to be visible, if needs to be in the viewport of all its
177             // ancestors.
178             // Note: For now we run both of these concurrently, but in the future we could
179             // make this configurable. (The child relocation could be executed before the
180             // parent, or parent before the child).
181             launch {
182                 // Bring the requested Child into this parent's view.
183                 responder.bringChildIntoView(::localRect)
184             }
185 
186             // Launch this as well so that if the parent is cancelled (this throws a CE) due to
187             // animation interruption, the child continues animating. If we just call
188             // bringChildIntoView directly without launching, if that function throws a
189             // CancellationException, it will cancel this coroutineScope, which will also cancel the
190             // responder's coroutine.
191             launch { bringIntoView(parentRect) }
192         }
193     }
194 }
195 
196 /** Translates [rect], specified in [sourceCoordinates], into this [LayoutCoordinates]. */
LayoutCoordinatesnull197 private fun LayoutCoordinates.localRectOf(sourceCoordinates: LayoutCoordinates, rect: Rect): Rect {
198     // Translate the supplied layout coordinates into the coordinate system of this parent.
199     val localRect = localBoundingBoxOf(sourceCoordinates, clipBounds = false)
200 
201     // Translate the rect to this parent's local coordinates.
202     return rect.translate(localRect.topLeft)
203 }
204