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