1 /*
2 * Copyright 2022 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:JvmName("NestedScrollInteropConnectionKt")
18
19 package androidx.compose.ui.platform
20
21 import android.view.View
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.remember
24 import androidx.compose.ui.geometry.Offset
25 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
26 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
27 import androidx.compose.ui.unit.Velocity
28 import androidx.core.view.NestedScrollingChildHelper
29 import androidx.core.view.ViewCompat
30 import androidx.core.view.ViewCompat.TYPE_NON_TOUCH
31 import androidx.core.view.ViewCompat.TYPE_TOUCH
32 import kotlin.math.absoluteValue
33 import kotlin.math.ceil
34 import kotlin.math.floor
35
36 /**
37 * Adapts nested scroll from View to Compose. This class is used by [ComposeView] to bridge nested
38 * scrolling across View and Compose. It acts as both:
39 * 1) [androidx.core.view.NestedScrollingChild3] by using an instance of
40 * [NestedScrollingChildHelper] to dispatch scroll deltas up to a consuming parent on the view
41 * side.
42 * 2) [NestedScrollingChildHelper] by implementing this interface it should be able to receive
43 * deltas from dispatching children on the Compose side.
44 */
45 internal class NestedScrollInteropConnection(private val view: View) : NestedScrollConnection {
46
47 private val nestedScrollChildHelper =
<lambda>null48 NestedScrollingChildHelper(view).apply { isNestedScrollingEnabled = true }
49
50 private val consumedScrollCache = IntArray(2)
51
52 init {
53 // Enables nested scrolling for the root view [AndroidComposeView].
54 // Like in Compose, nested scrolling is a default implementation
55 ViewCompat.setNestedScrollingEnabled(view, true)
56 }
57
onPreScrollnull58 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
59 // Using the return of startNestedScroll to determine if nested scrolling will happen.
60 if (nestedScrollChildHelper.startNestedScroll(available.scrollAxes, source.toViewType())) {
61 // reuse
62 consumedScrollCache.fill(0)
63
64 nestedScrollChildHelper.dispatchNestedPreScroll(
65 composeToViewOffset(available.x),
66 composeToViewOffset(available.y),
67 consumedScrollCache,
68 null,
69 source.toViewType()
70 )
71
72 return toOffset(consumedScrollCache, available)
73 }
74
75 return Offset.Zero
76 }
77
onPostScrollnull78 override fun onPostScroll(
79 consumed: Offset,
80 available: Offset,
81 source: NestedScrollSource
82 ): Offset {
83 // Using the return of startNestedScroll to determine if nested scrolling will happen.
84 if (nestedScrollChildHelper.startNestedScroll(available.scrollAxes, source.toViewType())) {
85 consumedScrollCache.fill(0)
86
87 nestedScrollChildHelper.dispatchNestedScroll(
88 composeToViewOffset(consumed.x),
89 composeToViewOffset(consumed.y),
90 composeToViewOffset(available.x),
91 composeToViewOffset(available.y),
92 null,
93 source.toViewType(),
94 consumedScrollCache,
95 )
96
97 return toOffset(consumedScrollCache, available)
98 }
99
100 return Offset.Zero
101 }
102
onPreFlingnull103 override suspend fun onPreFling(available: Velocity): Velocity {
104
105 val result =
106 if (
107 nestedScrollChildHelper.dispatchNestedPreFling(
108 available.x.toViewVelocity(),
109 available.y.toViewVelocity(),
110 )
111 ) {
112 available
113 } else {
114 Velocity.Zero
115 }
116
117 interruptOngoingScrolls()
118
119 return result
120 }
121
onPostFlingnull122 override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
123 val result =
124 if (
125 nestedScrollChildHelper.dispatchNestedFling(
126 available.x.toViewVelocity(),
127 available.y.toViewVelocity(),
128 true
129 )
130 ) {
131 available
132 } else {
133 Velocity.Zero
134 }
135
136 interruptOngoingScrolls()
137
138 return result
139 }
140
interruptOngoingScrollsnull141 private fun interruptOngoingScrolls() {
142 if (nestedScrollChildHelper.hasNestedScrollingParent(TYPE_TOUCH)) {
143 nestedScrollChildHelper.stopNestedScroll(TYPE_TOUCH)
144 }
145
146 if (nestedScrollChildHelper.hasNestedScrollingParent(TYPE_NON_TOUCH)) {
147 nestedScrollChildHelper.stopNestedScroll(TYPE_NON_TOUCH)
148 }
149 }
150 }
151
152 // Relative ceil for rounding. Ceiling away from zero to avoid missing scrolling deltas to rounding
153 // issues.
Floatnull154 private fun Float.ceilAwayFromZero(): Float = if (this >= 0) ceil(this) else floor(this)
155
156 // Compose coordinate system is the opposite of view's system
157 internal fun composeToViewOffset(offset: Float): Int = offset.ceilAwayFromZero().toInt() * -1
158
159 // Compose scrolling sign system is the opposite of view's system
160 private fun Int.reverseAxis(): Float = this * -1f
161
162 private fun Float.toViewVelocity(): Float = this * -1f
163
164 /**
165 * Converts the view world array into compose [Offset] entity. This is bound by the values in the
166 * available [Offset] in order to account for rounding errors produced by the Int to Float
167 * conversions.
168 */
169 private fun toOffset(consumed: IntArray, available: Offset): Offset {
170 val offsetX =
171 if (available.x >= 0) {
172 consumed[0].reverseAxis().coerceAtMost(available.x)
173 } else {
174 consumed[0].reverseAxis().coerceAtLeast(available.x)
175 }
176
177 val offsetY =
178 if (available.y >= 0) {
179 consumed[1].reverseAxis().coerceAtMost(available.y)
180 } else {
181 consumed[1].reverseAxis().coerceAtLeast(available.y)
182 }
183
184 return Offset(offsetX, offsetY)
185 }
186
NestedScrollSourcenull187 private fun NestedScrollSource.toViewType(): Int =
188 when (this) {
189 NestedScrollSource.UserInput -> TYPE_TOUCH
190 else -> TYPE_NON_TOUCH
191 }
192
193 // TODO (levima) Maybe use a more accurate threshold?
194 private const val ScrollingAxesThreshold = 0.5f
195
196 /**
197 * Make an assumption that the scrolling axes is determined by a threshold of 0.5 on either
198 * direction.
199 */
200 private val Offset.scrollAxes: Int
201 get() {
202 var axes = ViewCompat.SCROLL_AXIS_NONE
203 if (x.absoluteValue >= ScrollingAxesThreshold) {
204 axes = axes or ViewCompat.SCROLL_AXIS_HORIZONTAL
205 }
206 if (y.absoluteValue >= ScrollingAxesThreshold) {
207 axes = axes or ViewCompat.SCROLL_AXIS_VERTICAL
208 }
209 return axes
210 }
211
212 /**
213 * Create and [remember] the [NestedScrollConnection] that enables Nested Scroll Interop between a
214 * View parent that implements [androidx.core.view.NestedScrollingParent3] and a Compose child. This
215 * should be used in conjunction with a [androidx.compose.ui.input.nestedscroll.nestedScroll]
216 * modifier. Nested Scroll is enabled by default on the compose side and you can use this connection
217 * to enable both nested scroll on the view side and to add glue logic between View and compose.
218 *
219 * Note that this only covers the use case where a cooperating parent is used. A cooperating parent
220 * is one that implements NestedScrollingParent3, a key layout that does that is
221 * [androidx.coordinatorlayout.widget.CoordinatorLayout].
222 *
223 * @param hostView The View that hosts the compose scrollable, this is usually a ComposeView.
224 *
225 * Learn how to enable nested scroll interop:
226 *
227 * @sample androidx.compose.ui.samples.ComposeInCooperatingViewNestedScrollInteropSample
228 */
229 @Composable
rememberNestedScrollInteropConnectionnull230 fun rememberNestedScrollInteropConnection(
231 hostView: View = LocalView.current
232 ): NestedScrollConnection = remember(hostView) { NestedScrollInteropConnection(hostView) }
233