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