/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.platform
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import androidx.core.view.NestedScrollingChildHelper
import androidx.core.view.ViewCompat
import androidx.core.view.ViewCompat.TYPE_NON_TOUCH
import androidx.core.view.ViewCompat.TYPE_TOUCH
import kotlin.math.absoluteValue
import kotlin.math.ceil
import kotlin.math.floor
/**
* Adapts nested scroll from View to Compose. This class is used by [ComposeView] to bridge
* nested scrolling across View and Compose. It acts as both:
* 1) [androidx.core.view.NestedScrollingChild3] by using an instance of
* [NestedScrollingChildHelper] to dispatch scroll deltas up to a consuming parent on the view side.
* 2) [NestedScrollingChildHelper] by implementing this interface it should be able to receive
* deltas from dispatching children on the Compose side.
*/
internal class NestedScrollInteropConnection(
private val view: View
) : NestedScrollConnection {
private val nestedScrollChildHelper = NestedScrollingChildHelper(view).apply {
isNestedScrollingEnabled = true
}
private val consumedScrollCache = IntArray(2)
init {
// Enables nested scrolling for the root view [AndroidComposeView].
// Like in Compose, nested scrolling is a default implementation
ViewCompat.setNestedScrollingEnabled(view, true)
}
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Using the return of startNestedScroll to determine if nested scrolling will happen.
if (nestedScrollChildHelper.startNestedScroll(
available.scrollAxes,
source.toViewType()
)
) {
// reuse
consumedScrollCache.fill(0)
nestedScrollChildHelper.dispatchNestedPreScroll(
composeToViewOffset(available.x),
composeToViewOffset(available.y),
consumedScrollCache,
null,
source.toViewType()
)
return toOffset(consumedScrollCache, available)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Using the return of startNestedScroll to determine if nested scrolling will happen.
if (nestedScrollChildHelper.startNestedScroll(
available.scrollAxes,
source.toViewType()
)
) {
consumedScrollCache.fill(0)
nestedScrollChildHelper.dispatchNestedScroll(
composeToViewOffset(consumed.x),
composeToViewOffset(consumed.y),
composeToViewOffset(available.x),
composeToViewOffset(available.y),
null,
source.toViewType(),
consumedScrollCache,
)
return toOffset(consumedScrollCache, available)
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
val result = if (nestedScrollChildHelper.dispatchNestedPreFling(
available.x.toViewVelocity(),
available.y.toViewVelocity(),
)
) {
available
} else {
Velocity.Zero
}
interruptOngoingScrolls()
return result
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val result = if (nestedScrollChildHelper.dispatchNestedFling(
available.x.toViewVelocity(),
available.y.toViewVelocity(),
true
)
) {
available
} else {
Velocity.Zero
}
interruptOngoingScrolls()
return result
}
private fun interruptOngoingScrolls() {
if (nestedScrollChildHelper.hasNestedScrollingParent(TYPE_TOUCH)) {
nestedScrollChildHelper.stopNestedScroll(TYPE_TOUCH)
}
if (nestedScrollChildHelper.hasNestedScrollingParent(TYPE_NON_TOUCH)) {
nestedScrollChildHelper.stopNestedScroll(TYPE_NON_TOUCH)
}
}
}
// Relative ceil for rounding. Ceiling away from zero to avoid missing scrolling deltas to rounding
// issues.
private fun Float.ceilAwayFromZero(): Float = if (this >= 0) ceil(this) else floor(this)
// Compose coordinate system is the opposite of view's system
internal fun composeToViewOffset(offset: Float): Int = offset.ceilAwayFromZero().toInt() * -1
// Compose scrolling sign system is the opposite of view's system
private fun Int.reverseAxis(): Float = this * -1f
private fun Float.toViewVelocity(): Float = this * -1f
/**
* Converts the view world array into compose [Offset] entity. This is bound by the values in the
* available [Offset] in order to account for rounding errors produced by the Int to Float
* conversions.
*/
private fun toOffset(consumed: IntArray, available: Offset): Offset {
val offsetX = if (available.x >= 0) {
consumed[0].reverseAxis().coerceAtMost(available.x)
} else {
consumed[0].reverseAxis().coerceAtLeast(available.x)
}
val offsetY = if (available.y >= 0) {
consumed[1].reverseAxis().coerceAtMost(available.y)
} else {
consumed[1].reverseAxis().coerceAtLeast(available.y)
}
return Offset(offsetX, offsetY)
}
private fun NestedScrollSource.toViewType(): Int = when (this) {
NestedScrollSource.Drag -> TYPE_TOUCH
else -> TYPE_NON_TOUCH
}
// TODO (levima) Maybe use a more accurate threshold?
private const val ScrollingAxesThreshold = 0.5f
/**
* Make an assumption that the scrolling axes is determined by a threshold of 0.5 on either
* direction.
*/
private val Offset.scrollAxes: Int
get() {
var axes = ViewCompat.SCROLL_AXIS_NONE
if (x.absoluteValue >= ScrollingAxesThreshold) {
axes = axes or ViewCompat.SCROLL_AXIS_HORIZONTAL
}
if (y.absoluteValue >= ScrollingAxesThreshold) {
axes = axes or ViewCompat.SCROLL_AXIS_VERTICAL
}
return axes
}
/**
* Create and [remember] the [NestedScrollConnection] that enables Nested Scroll Interop
* between a View parent that implements [androidx.core.view.NestedScrollingParent3] and a
* Compose child. This should be used in conjunction with a
* [androidx.compose.ui.input.nestedscroll.nestedScroll] modifier. Nested Scroll is enabled by
* default on the compose side and you can use this connection to enable both nested scroll on the
* view side and to add glue logic between View and compose.
*
* Note that this only covers the use case where a cooperating parent is used. A cooperating parent
* is one that implements NestedScrollingParent3, a key layout that does that is
* [androidx.coordinatorlayout.widget.CoordinatorLayout].
*
* Learn how to enable nested scroll interop:
* @sample androidx.compose.ui.samples.ComposeInCooperatingViewNestedScrollInteropSample
*
*/
@ExperimentalComposeUiApi
@Composable
fun rememberNestedScrollInteropConnection(): NestedScrollConnection {
val composeView = LocalView.current
return remember(composeView) {
NestedScrollInteropConnection(composeView)
}
}