Merge "Implement IndirectTouchScrollable in glimmer" into androidx-main
diff --git a/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/IndirectTouchScrollableTest.kt b/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/IndirectTouchScrollableTest.kt
new file mode 100644
index 0000000..ec9188a
--- /dev/null
+++ b/xr/glimmer/glimmer/src/androidTest/kotlin/androidx/xr/glimmer/IndirectTouchScrollableTest.kt
@@ -0,0 +1,330 @@
+/*
+ * Copyright 2025 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.xr.glimmer
+
+import android.os.SystemClock
+import android.view.MotionEvent
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.ExperimentalIndirectTouchTypeApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusTarget
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.indirect.IndirectTouchEvent
+import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performIndirectTouchEvent
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class IndirectTouchScrollableTest {
+
+    @get:Rule val rule = createComposeRule()
+
+    private val scrollableBoxTag = "scrollableBox"
+
+    private lateinit var scope: CoroutineScope
+
+    private val focusRequester = FocusRequester()
+
+    private fun ComposeContentTestRule.setContentAndGetScope(content: @Composable () -> Unit) {
+        setContent {
+            val actualScope = rememberCoroutineScope()
+            SideEffect { scope = actualScope }
+            content()
+        }
+    }
+
+    @Before
+    fun before() {
+        isDebugInspectorInfoEnabled = true
+    }
+
+    @After
+    fun after() {
+        isDebugInspectorInfoEnabled = false
+    }
+
+    @Test
+    fun indirectTouchScrollable_horizontal_consumesEventsAccordingly() {
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent {
+            Modifier.indirectTouchScrollable(
+                state = controller,
+                orientation = Orientation.Horizontal,
+            )
+        }
+
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+
+        rule.runOnIdle { assertThat(total).isGreaterThan(0) }
+
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeBackward()
+        rule.runOnIdle { assertThat(total).isWithin(0.01f).of(0.0f) }
+    }
+
+    @Test
+    fun indirectTouchScrollable_horizontalScroll_reverse() {
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent {
+            Modifier.indirectTouchScrollable(
+                reverseDirection = true,
+                state = controller,
+                orientation = Orientation.Horizontal,
+            )
+        }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+
+        rule.runOnIdle { assertThat(total).isLessThan(0) }
+
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeBackward()
+        rule.runOnIdle { assertThat(total).isWithin(0.01f).of(0.0f) }
+    }
+
+    @Test
+    fun indirectTouchScrollable_vertical_consumesEventsAccordingly() {
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent {
+            Modifier.indirectTouchScrollable(state = controller, orientation = Orientation.Vertical)
+        }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+
+        rule.runOnIdle { assertThat(total).isGreaterThan(0) }
+
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeBackward()
+        rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
+    }
+
+    @Test
+    fun indirectTouchScrollable_verticalScroll_reverse() {
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent {
+            Modifier.indirectTouchScrollable(
+                reverseDirection = true,
+                state = controller,
+                orientation = Orientation.Vertical,
+            )
+        }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+
+        rule.runOnIdle { assertThat(total).isLessThan(0) }
+
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeBackward()
+        rule.runOnIdle { assertThat(total).isLessThan(0.01f) }
+    }
+
+    @Test
+    fun indirectTouchScrollable_disabledWontCallLambda() {
+        val enabled = mutableStateOf(true)
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent {
+            Modifier.indirectTouchScrollable(
+                state = controller,
+                orientation = Orientation.Horizontal,
+                enabled = enabled.value,
+            )
+        }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+        val prevTotal =
+            rule.runOnIdle {
+                assertThat(total).isGreaterThan(0f)
+                enabled.value = false
+                total
+            }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+        rule.runOnIdle { assertThat(total).isEqualTo(prevTotal) }
+    }
+
+    @Test
+    fun indirectTouchScrollable_notFocusedWontCallLambda() {
+        var total = 0f
+        val controller =
+            ScrollableState(
+                consumeScrollDelta = {
+                    total += it
+                    it
+                }
+            )
+        setScrollableContent(false) {
+            Modifier.indirectTouchScrollable(
+                state = controller,
+                orientation = Orientation.Horizontal,
+            )
+        }
+        rule.onNodeWithTag(scrollableBoxTag).sendIndirectSwipeForward()
+        rule.runOnIdle { assertThat(total).isEqualTo(0f) }
+    }
+
+    private fun setScrollableContent(
+        enableInitialFocus: Boolean = true,
+        scrollableModifierFactory: @Composable () -> Modifier,
+    ) {
+
+        rule.setContentAndGetScope {
+            Box {
+                val scrollable = scrollableModifierFactory()
+                val initialFocus =
+                    if (enableInitialFocus) {
+                        Modifier.focusRequester(focusRequester).focusTarget()
+                    } else {
+                        Modifier
+                    }
+                Box(
+                    modifier =
+                        Modifier.testTag(scrollableBoxTag)
+                            .size(100.dp)
+                            .then(scrollable)
+                            .then(initialFocus)
+                )
+            }
+        }
+
+        if (enableInitialFocus)
+            rule.runOnIdle { assertThat(focusRequester.requestFocus()).isTrue() }
+    }
+}
+
+/** Synthetically range the x movements from 1000 to 0 */
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+private fun SemanticsNodeInteraction.sendIndirectSwipeEvent(
+    from: Float,
+    to: Float,
+    touchpadWidth: Float = TouchPadEnd - TouchPadStart,
+    stepCount: Int = 10,
+    delayTimeMills: Long = 200L,
+) {
+    require(stepCount > 0) { "Step count should be at least 1" }
+    val stepSize = touchpadWidth / stepCount
+    val sign = if (from > to) -1 else 1
+
+    var currentTime = SystemClock.uptimeMillis()
+    var currentValue = from
+
+    val down =
+        MotionEvent.obtain(
+            currentTime, // downTime,
+            currentTime, // eventTime,
+            MotionEvent.ACTION_DOWN,
+            currentValue,
+            Offset.Zero.y,
+            0,
+        )
+    performIndirectTouchEvent(IndirectTouchEvent(down))
+    currentTime += delayTimeMills
+    currentValue += sign * stepSize
+
+    repeat(stepCount) {
+        val move =
+            MotionEvent.obtain(
+                currentTime,
+                currentTime,
+                MotionEvent.ACTION_MOVE,
+                currentValue,
+                Offset.Zero.y,
+                0,
+            )
+        if (it != stepCount - 1) {
+            currentTime += delayTimeMills
+            currentValue += sign * stepSize
+        }
+        performIndirectTouchEvent(IndirectTouchEvent(move))
+    }
+
+    val up =
+        MotionEvent.obtain(
+            currentTime,
+            currentTime,
+            MotionEvent.ACTION_UP,
+            currentValue,
+            Offset.Zero.y,
+            0,
+        )
+    performIndirectTouchEvent(IndirectTouchEvent(up))
+}
+
+/** Swiping towards the start of the touchpad */
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+private fun SemanticsNodeInteraction.sendIndirectSwipeBackward() {
+    sendIndirectSwipeEvent(TouchPadEnd, TouchPadStart)
+}
+
+/** Swiping towards the end of the touchpad. */
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+private fun SemanticsNodeInteraction.sendIndirectSwipeForward() {
+    sendIndirectSwipeEvent(TouchPadStart, TouchPadEnd)
+}
+
+private const val TouchPadEnd = 1000f
+private const val TouchPadStart = 0f
diff --git a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/IndirectTouchScrollable.kt b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/IndirectTouchScrollable.kt
new file mode 100644
index 0000000..b169e55
--- /dev/null
+++ b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/IndirectTouchScrollable.kt
@@ -0,0 +1,665 @@
+/*
+ * Copyright 2025 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.xr.glimmer
+
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.DecayAnimationSpec
+import androidx.compose.animation.core.animateDecay
+import androidx.compose.animation.splineBasedDecay
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.Orientation.Horizontal
+import androidx.compose.foundation.gestures.Orientation.Vertical
+import androidx.compose.foundation.gestures.ScrollScope
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.ui.ExperimentalIndirectTouchTypeApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.MotionDurationScale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.indirect.IndirectTouchEvent
+import androidx.compose.ui.input.indirect.IndirectTouchEventType
+import androidx.compose.ui.input.indirect.IndirectTouchInputModifierNode
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.currentValueOf
+import androidx.compose.ui.node.requireDensity
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.ViewConfiguration
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.util.fastMap
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.math.sign
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Configure indirect touch scrolling and flinging for the UI element in a single [Orientation].
+ *
+ * TODO(levima) Remove once upstreamed
+ */
+internal fun Modifier.indirectTouchScrollable(
+    state: ScrollableState,
+    orientation: Orientation,
+    enabled: Boolean = true,
+    reverseDirection: Boolean = false,
+    flingBehavior: FlingBehavior? = null,
+    interactionSource: MutableInteractionSource? = null,
+) =
+    this then
+        IndirectTouchScrollableElement(
+            state,
+            orientation,
+            enabled,
+            reverseDirection,
+            flingBehavior,
+            interactionSource,
+        )
+
+private class IndirectTouchScrollableElement(
+    val state: ScrollableState,
+    val orientation: Orientation,
+    val enabled: Boolean,
+    val reverseDirection: Boolean,
+    val flingBehavior: FlingBehavior?,
+    val interactionSource: MutableInteractionSource?,
+) : ModifierNodeElement() {
+    override fun create(): IndirectTouchScrollableNode {
+        return IndirectTouchScrollableNode(
+            state,
+            flingBehavior,
+            orientation,
+            enabled,
+            reverseDirection,
+            interactionSource,
+        )
+    }
+
+    override fun update(node: IndirectTouchScrollableNode) {
+        node.update(state, orientation, enabled, reverseDirection, flingBehavior, interactionSource)
+    }
+
+    override fun hashCode(): Int {
+        var result = state.hashCode()
+        result = 31 * result + orientation.hashCode()
+        result = 31 * result + enabled.hashCode()
+        result = 31 * result + reverseDirection.hashCode()
+        result = 31 * result + flingBehavior.hashCode()
+        result = 31 * result + interactionSource.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+
+        if (other !is IndirectTouchScrollableElement) return false
+
+        if (state != other.state) return false
+        if (orientation != other.orientation) return false
+        if (enabled != other.enabled) return false
+        if (reverseDirection != other.reverseDirection) return false
+        if (flingBehavior != other.flingBehavior) return false
+        if (interactionSource != other.interactionSource) return false
+
+        return true
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "indirectTouchScrollable"
+        properties["orientation"] = orientation
+        properties["state"] = state
+        properties["enabled"] = enabled
+        properties["reverseDirection"] = reverseDirection
+        properties["flingBehavior"] = flingBehavior
+        properties["interactionSource"] = interactionSource
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+internal class IndirectTouchScrollableNode(
+    private var scrollableState: ScrollableState,
+    private var flingBehavior: FlingBehavior?,
+    private var orientation: Orientation,
+    private var enabled: Boolean,
+    private var reverseDirection: Boolean,
+    private var interactionSource: MutableInteractionSource?,
+) : DelegatingNode(), IndirectTouchInputModifierNode, CompositionLocalConsumerModifierNode {
+
+    override val shouldAutoInvalidate: Boolean = false
+
+    // Place holder fling behavior, we'll initialize it when the density is available.
+    private val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnitDensity))
+    private var indirectDragInteraction: DragInteraction.Start? = null
+    private var indirectTouchEventProcessor: IndirectTouchEventProcessor? = null
+    private var touchInputEventSmoother: TouchInputEventSmoother? = null
+
+    fun update(
+        scrollableState: ScrollableState,
+        orientation: Orientation,
+        enabled: Boolean,
+        reverseDirection: Boolean,
+        flingBehavior: FlingBehavior?,
+        interactionSource: MutableInteractionSource?,
+    ) {
+        var resetInputHandling = false
+        if (this.enabled != enabled) { // enabled changed
+            this.enabled = enabled
+            resetInputHandling = true
+        }
+
+        if (this.scrollableState != scrollableState) {
+            this.scrollableState = scrollableState
+            resetInputHandling = true
+        }
+
+        if (this.orientation != orientation) {
+            this.orientation = orientation
+            resetInputHandling = true
+        }
+        if (this.reverseDirection != reverseDirection) {
+            this.reverseDirection = reverseDirection
+            resetInputHandling = true
+        }
+        this.flingBehavior = flingBehavior
+        this.interactionSource = interactionSource
+
+        if (resetInputHandling) {
+            indirectTouchEventProcessor?.resetProcessor()
+        }
+    }
+
+    override fun onAttach() {
+        updateDefaultFlingBehavior()
+    }
+
+    private fun updateDefaultFlingBehavior() {
+        if (!isAttached) return
+        val density = requireDensity()
+        defaultFlingBehavior.flingDecay = splineBasedDecay(density)
+    }
+
+    override fun onDensityChange() {
+        indirectTouchEventProcessor?.resetProcessor()
+        updateDefaultFlingBehavior()
+    }
+
+    private fun onTouchEventRelease(velocity: Velocity) {
+        coroutineScope.launch { doFlingAnimation(velocity) }
+    }
+
+    private fun startEventsListenerIfNeeded() {
+        if (indirectTouchEventProcessor == null) {
+            indirectTouchEventProcessor = IndirectTouchEventProcessor()
+            touchInputEventSmoother = TouchInputEventSmoother()
+            // start listening to events
+            coroutineScope.launch {
+                while (isActive) {
+                    var event = indirectTouchEventProcessor?.scrollEvents?.receive()
+                    if (event !is ScrollEvent.ScrollStarted) continue
+                    processScrollStart()
+                    try {
+                        scroll(scrollPriority = MutatePriority.UserInput) {
+                            while (
+                                event !is ScrollEvent.ScrollStopped &&
+                                    event !is ScrollEvent.ScrollCancelled
+                            ) {
+                                val deltaEvent = (event as? ScrollEvent.ScrollDelta)
+                                deltaEvent?.let {
+                                    scrollBy(deltaEvent.delta.toMeaningfulAxisOffset().toFloat())
+                                }
+                                event = indirectTouchEventProcessor?.scrollEvents?.receive()
+                            }
+                        }
+                        if (event is ScrollEvent.ScrollStopped) {
+                            processScrollStop(event as ScrollEvent.ScrollStopped)
+                        } else if (event is ScrollEvent.ScrollCancelled) {
+                            processScrollCancel()
+                        }
+                    } catch (c: CancellationException) {
+                        processScrollCancel()
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onIndirectTouchEvent(event: IndirectTouchEvent): Boolean {
+        if (!enabled) return false
+
+        startEventsListenerIfNeeded()
+
+        return indirectTouchEventProcessor?.processIndirectTouchEvent(
+            event.type,
+            event.uptimeMillis,
+            touchInputEventSmoother!!.smoothEventPosition(event),
+            orientation,
+            currentValueOf(LocalViewConfiguration),
+        ) ?: false
+    }
+
+    override fun onPreIndirectTouchEvent(event: IndirectTouchEvent): Boolean = false
+
+    private suspend fun processScrollStart() {
+        indirectDragInteraction?.let { oldInteraction ->
+            interactionSource?.emit(DragInteraction.Cancel(oldInteraction))
+        }
+        val interaction = DragInteraction.Start()
+        interactionSource?.emit(interaction)
+        indirectDragInteraction = interaction
+    }
+
+    private suspend fun processScrollStop(event: ScrollEvent.ScrollStopped) {
+        indirectDragInteraction?.let { interaction ->
+            interactionSource?.emit(DragInteraction.Stop(interaction))
+            indirectDragInteraction = null
+        }
+        onTouchEventRelease(event.velocity)
+    }
+
+    private suspend fun processScrollCancel() {
+        indirectDragInteraction?.let { interaction ->
+            interactionSource?.emit(DragInteraction.Cancel(interaction))
+            indirectDragInteraction = null
+        }
+        onTouchEventRelease(Velocity.Zero)
+    }
+
+    fun disposeInteractionSource() {
+        indirectDragInteraction?.let { interaction ->
+            interactionSource?.tryEmit(DragInteraction.Cancel(interaction))
+            indirectDragInteraction = null
+        }
+    }
+
+    // specifies if this scrollable node is currently flinging
+    var isFlinging = false
+        private set
+
+    fun Float.toOffset(): Offset =
+        when {
+            this == 0f -> Offset.Zero
+            orientation == Horizontal -> Offset(this, 0f)
+            else -> Offset(0f, this)
+        }
+
+    fun Offset.singleAxisOffset(): Offset =
+        if (orientation == Horizontal) copy(y = 0f) else copy(x = 0f)
+
+    fun Offset.toFloat(): Float = if (orientation == Horizontal) this.x else this.y
+
+    fun Float.toVelocity(): Velocity =
+        when {
+            this == 0f -> Velocity.Zero
+            orientation == Horizontal -> Velocity(this, 0f)
+            else -> Velocity(0f, this)
+        }
+
+    private fun Velocity.toFloat(): Float = if (orientation == Horizontal) this.x else this.y
+
+    private fun Velocity.singleAxisVelocity(): Velocity =
+        if (orientation == Horizontal) copy(y = 0f) else copy(x = 0f)
+
+    private fun Velocity.update(newValue: Float): Velocity =
+        if (orientation == Horizontal) copy(x = newValue) else copy(y = newValue)
+
+    fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
+
+    fun Offset.reverseIfNeeded(): Offset = if (reverseDirection) this * -1f else this
+
+    /**
+     * Converts deltas from the "relevant" axis in the input event velocity to the "meaningful"
+     * axis, that is, the axis that this scrollable consumes events.
+     */
+    fun Offset.toMeaningfulAxisOffset(): Offset {
+        val offset = this.toRelevantAxis()
+        return if (orientation == Horizontal) Offset(offset, 0f) else Offset(0f, offset)
+    }
+
+    private var outerStateScope = NoOpScrollScope
+
+    private var dispatchingScope =
+        object : ScrollScope {
+            override fun scrollBy(pixels: Float): Float {
+                return with(outerStateScope) { performScroll(pixels.toOffset()).toFloat() }
+            }
+        }
+
+    private fun ScrollScope.performScroll(delta: Offset): Offset {
+        val singleAxisDeltaForSelfScroll = delta.singleAxisOffset().reverseIfNeeded().toFloat()
+
+        // Consume on a single axis.
+        val consumedBySelfScroll =
+            scrollBy(singleAxisDeltaForSelfScroll).toOffset().reverseIfNeeded()
+
+        return consumedBySelfScroll
+    }
+
+    fun performRawScroll(scroll: Offset): Offset {
+        return if (scrollableState.isScrollInProgress) {
+            Offset.Zero
+        } else {
+            dispatchRawDelta(scroll)
+        }
+    }
+
+    private fun dispatchRawDelta(scroll: Offset): Offset {
+        return scrollableState
+            .dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
+            .reverseIfNeeded()
+            .toOffset()
+    }
+
+    private suspend fun doFlingAnimation(available: Velocity): Velocity {
+        var result: Velocity = available
+        isFlinging = true
+        try {
+            scroll(scrollPriority = MutatePriority.Default) {
+                val outerScrollScope = this
+                val reverseScope =
+                    object : ScrollScope {
+                        override fun scrollBy(pixels: Float): Float {
+                            // Fling has hit the bounds or node left composition,
+                            // cancel it to allow continuation. This will conclude this node's
+                            // fling,
+                            // allowing the onPostFling signal to be called
+                            // with the leftover velocity from the fling animation. Any nested
+                            // scroll
+                            // node above will be able to pick up the left over velocity and
+                            // continue
+                            // the fling.
+                            if (pixels.absoluteValue != 0.0f && !isAttached) {
+                                throw FlingCancellationException()
+                            }
+
+                            return outerScrollScope
+                                .scrollBy(pixels.toOffset().reverseIfNeeded().toFloat())
+                                .toFloat()
+                                .reverseIfNeeded()
+                        }
+                    }
+                with(reverseScope) {
+                    val resolvedFling = flingBehavior ?: defaultFlingBehavior
+                    with(resolvedFling) {
+                        result =
+                            result.update(
+                                performFling(available.toFloat().reverseIfNeeded())
+                                    .reverseIfNeeded()
+                            )
+                    }
+                }
+            }
+        } finally {
+            isFlinging = false
+        }
+
+        return result
+    }
+
+    fun shouldScrollImmediately(): Boolean {
+        return scrollableState.isScrollInProgress
+    }
+
+    /** Opens a scrolling session with nested scrolling and overscroll support. */
+    private suspend fun scroll(
+        scrollPriority: MutatePriority = MutatePriority.Default,
+        block: suspend ScrollScope.() -> Unit,
+    ) {
+        scrollableState.scroll(scrollPriority) {
+            outerStateScope = this
+            block.invoke(dispatchingScope)
+        }
+    }
+
+    fun isVertical(): Boolean = orientation == Vertical
+}
+
+private val NoOpScrollScope: ScrollScope =
+    object : ScrollScope {
+        override fun scrollBy(pixels: Float): Float = pixels
+    }
+
+/** A scroll scope for nested scrolling and overscroll support. */
+private interface NestedScrollScope {
+    fun scrollBy(offset: Offset, source: NestedScrollSource): Offset
+
+    fun scrollByWithOverscroll(offset: Offset, source: NestedScrollSource): Offset
+}
+
+internal val UnitDensity =
+    object : Density {
+        override val density: Float
+            get() = 1f
+
+        override val fontScale: Float
+            get() = 1f
+    }
+
+internal class DefaultFlingBehavior(
+    var flingDecay: DecayAnimationSpec,
+    private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale,
+) : FlingBehavior {
+
+    // For Testing
+    var lastAnimationCycleCount = 0
+
+    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
+        lastAnimationCycleCount = 0
+        // come up with the better threshold, but we need it since spline curve gives us NaNs
+        return withContext(motionDurationScale) {
+            if (abs(initialVelocity) > 1f) {
+                var velocityLeft = initialVelocity
+                var lastValue = 0f
+                val animationState =
+                    AnimationState(initialValue = 0f, initialVelocity = initialVelocity)
+                try {
+                    animationState.animateDecay(flingDecay) {
+                        val delta = value - lastValue
+                        val consumed = scrollBy(delta)
+                        lastValue = value
+                        velocityLeft = this.velocity
+                        // avoid rounding errors and stop if anything is unconsumed
+                        if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
+                        lastAnimationCycleCount++
+                    }
+                } catch (e: CancellationException) {
+                    velocityLeft = animationState.velocity
+                }
+                velocityLeft
+            } else {
+                initialVelocity
+            }
+        }
+    }
+}
+
+private const val DefaultScrollMotionDurationScaleFactor = 1f
+private val DefaultScrollMotionDurationScale =
+    object : MotionDurationScale {
+        override val scaleFactor: Float
+            get() = DefaultScrollMotionDurationScaleFactor
+    }
+
+private class FlingCancellationException :
+    CancellationException("The fling animation was cancelled")
+
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+private class IndirectTouchEventProcessor() {
+    private var velocityTracker: VelocityTracker? = null
+    private var hasCrossedTouchSlop = false
+    private var previousIndirectTouchPosition = Offset.Zero
+    private var positionAccumulator = Offset.Zero
+
+    val scrollEvents = Channel(capacity = Channel.UNLIMITED)
+
+    fun processIndirectTouchEvent(
+        eventType: IndirectTouchEventType,
+        eventTime: Long,
+        eventPosition: Offset,
+        orientation: Orientation,
+        viewConfiguration: ViewConfiguration,
+    ): Boolean {
+        if (velocityTracker == null) velocityTracker = VelocityTracker()
+
+        return when (eventType) {
+            IndirectTouchEventType.Press -> {
+                velocityTracker?.addPosition(eventTime, eventPosition)
+                previousIndirectTouchPosition = eventPosition
+                true
+            }
+
+            IndirectTouchEventType.Move -> {
+                var delta = eventPosition - previousIndirectTouchPosition
+                var consumed = false
+
+                if (!hasCrossedTouchSlop) {
+                    positionAccumulator += delta
+                    hasCrossedTouchSlop =
+                        abs(positionAccumulator.toRelevantAxis()) > viewConfiguration.touchSlop
+
+                    if (hasCrossedTouchSlop) {
+                        val newDelta =
+                            (abs(positionAccumulator.toRelevantAxis()) -
+                                viewConfiguration.touchSlop) *
+                                positionAccumulator.toRelevantAxis().sign
+                        delta = positionAccumulator.overrideRelevantAxis(newDelta)
+                        scrollEvents.trySend(ScrollEvent.ScrollStarted)
+                        scrollEvents.trySend(ScrollEvent.ScrollDelta(delta))
+                        consumed = true
+                    }
+                }
+
+                if (
+                    hasCrossedTouchSlop && delta.toRelevantAxis().absoluteValue > PixelSensitivity
+                ) {
+                    velocityTracker?.addPosition(eventTime, eventPosition)
+                    consumed = true
+                    scrollEvents.trySend(ScrollEvent.ScrollDelta(delta))
+                }
+                previousIndirectTouchPosition = eventPosition
+                consumed
+            }
+            IndirectTouchEventType.Release -> {
+                velocityTracker?.let { tracker ->
+                    val maxVelocity = viewConfiguration.maximumFlingVelocity
+                    val event =
+                        ScrollEvent.ScrollStopped(
+                            tracker
+                                .calculateVelocity(Velocity(maxVelocity, maxVelocity))
+                                .toMeaningfulAxisVelocity(orientation)
+                        )
+                    scrollEvents.trySend(event)
+                }
+
+                resetProcessor()
+                true
+            }
+            else -> {
+                scrollEvents.trySend(ScrollEvent.ScrollCancelled)
+                resetProcessor()
+                false
+            }
+        }
+    }
+
+    fun resetProcessor() {
+        previousIndirectTouchPosition = Offset.Zero
+        positionAccumulator = Offset.Zero
+        velocityTracker?.resetTracking()
+        hasCrossedTouchSlop = false
+    }
+
+    /**
+     * Converts deltas from the "relevant" axis in the input event velocity to the "meaningful"
+     * axis, that is, the axis that this scrollable consumes events.
+     */
+    private fun Velocity.toMeaningfulAxisVelocity(orientation: Orientation): Velocity {
+        val offset = this.toRelevantAxis()
+        return if (orientation == Horizontal) Velocity(offset, 0f) else Velocity(0f, offset)
+    }
+
+    companion object {
+        private const val PixelSensitivity = 2
+    }
+}
+
+private sealed class ScrollEvent {
+    object ScrollStarted : ScrollEvent()
+
+    object ScrollCancelled : ScrollEvent()
+
+    class ScrollStopped(val velocity: Velocity) : ScrollEvent()
+
+    class ScrollDelta(val delta: Offset) : ScrollEvent()
+}
+
+/** Smoothes touch input events that are too frequent and noisy */
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalIndirectTouchTypeApi::class)
+private class TouchInputEventSmoother() {
+    private var rotatingIndex = 0
+    private var rotatingArray = mutableListOf()
+
+    fun smoothEventPosition(event: IndirectTouchEvent): Offset {
+        var xPosition = event.position.x
+        var yPosition = event.position.y
+
+        if (event.type == IndirectTouchEventType.Press) {
+            rotatingIndex = 0
+            rotatingArray.clear()
+        }
+
+        if (event.type == IndirectTouchEventType.Move) {
+            if (rotatingArray.size == SmoothingFactor) {
+                rotatingArray[rotatingIndex] = event
+            } else {
+                rotatingArray.add(event)
+            }
+
+            if (rotatingIndex == SmoothingFactor) {
+                rotatingIndex = 0
+            }
+            xPosition = rotatingArray.fastMap { it.position.x }.average().toFloat()
+            yPosition = rotatingArray.fastMap { it.position.y }.average().toFloat()
+        }
+
+        return Offset(xPosition, yPosition)
+    }
+
+    companion object {
+        private const val SmoothingFactor = 3
+    }
+}
+
+/** Converts to the axis that is most representative of motion */
+private fun Offset.toRelevantAxis(): Float = x
+
+private fun Velocity.toRelevantAxis(): Float = x
+
+private fun Offset.overrideRelevantAxis(newValue: Float): Offset = Offset(x = newValue, y = this.y)