blob: cf929478e8675e39e8ef316aa33676f02bcd4ca6 [file] [log] [blame]
/*
* Copyright 2020 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.foundation.gestures
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.spring
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.FocusedBoundsObserverNode
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.gestures.BringIntoViewSpec.Companion.DefaultBringIntoViewSpec
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.relocation.BringIntoViewResponderNode
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.focus.FocusProperties
import androidx.compose.ui.focus.FocusPropertiesModifierNode
import androidx.compose.ui.focus.FocusTargetModifierNode
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyInputModifierNode
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Fling
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Wheel
import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.TraversableNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireDensity
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastForEach
import kotlin.math.abs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Configure touch scrolling and flinging for the UI element in a single [Orientation].
*
* Users should update their state themselves using default [ScrollableState] and its
* `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
* their own state in UI when using this component.
*
* If you don't need to have fling or nested scroll support, but want to make component simply
* draggable, consider using [draggable].
*
* @sample androidx.compose.foundation.samples.ScrollableSample
*
* @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
* interpreted by the user land logic and contains useful information about on-going events.
* @param orientation orientation of the scrolling
* @param enabled whether or not scrolling in enabled
* @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
* behave like bottom to top and left to right will behave like right to left.
* @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
* `null`, default from [ScrollableDefaults.flingBehavior] will be used.
* @param interactionSource [MutableInteractionSource] that will be used to emit
* drag events when this scrollable is being dragged.
*/
@Stable
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null
): Modifier = scrollable(
state = state,
orientation = orientation,
enabled = enabled,
reverseDirection = reverseDirection,
flingBehavior = flingBehavior,
interactionSource = interactionSource,
overscrollEffect = null
)
/**
* Configure touch scrolling and flinging for the UI element in a single [Orientation].
*
* Users should update their state themselves using default [ScrollableState] and its
* `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
* their own state in UI when using this component.
*
* If you don't need to have fling or nested scroll support, but want to make component simply
* draggable, consider using [draggable].
*
* This overload provides the access to [OverscrollEffect] that defines the behaviour of the
* over scrolling logic. Consider using [ScrollableDefaults.overscrollEffect] for the platform
* look-and-feel.
*
* @sample androidx.compose.foundation.samples.ScrollableSample
*
* @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
* interpreted by the user land logic and contains useful information about on-going events.
* @param orientation orientation of the scrolling
* @param overscrollEffect effect to which the deltas will be fed when the scrollable have
* some scrolling delta left. Pass `null` for no overscroll. If you pass an effect you should
* also apply [androidx.compose.foundation.overscroll] modifier.
* @param enabled whether or not scrolling in enabled
* @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
* behave like bottom to top and left to right will behave like right to left.
* @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
* `null`, default from [ScrollableDefaults.flingBehavior] will be used.
* @param interactionSource [MutableInteractionSource] that will be used to emit
* drag events when this scrollable is being dragged.
* @param bringIntoViewSpec The configuration that this scrollable should use to perform
* scrolling when scroll requests are received from the focus system.
*
* Note: This API is experimental as it brings support for some experimental features:
* [overscrollEffect] and [bringIntoViewSpec].
*/
@Stable
@ExperimentalFoundationApi
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?,
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null,
bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
) = this then ScrollableElement(
state,
orientation,
overscrollEffect,
enabled,
reverseDirection,
flingBehavior,
interactionSource,
bringIntoViewSpec
)
@OptIn(ExperimentalFoundationApi::class)
private class ScrollableElement(
val state: ScrollableState,
val orientation: Orientation,
val overscrollEffect: OverscrollEffect?,
val enabled: Boolean,
val reverseDirection: Boolean,
val flingBehavior: FlingBehavior?,
val interactionSource: MutableInteractionSource?,
val bringIntoViewSpec: BringIntoViewSpec
) : ModifierNodeElement<ScrollableNode>() {
override fun create(): ScrollableNode {
return ScrollableNode(
state,
overscrollEffect,
flingBehavior,
orientation,
enabled,
reverseDirection,
interactionSource,
bringIntoViewSpec
)
}
override fun update(node: ScrollableNode) {
node.update(
state,
orientation,
overscrollEffect,
enabled,
reverseDirection,
flingBehavior,
interactionSource,
bringIntoViewSpec
)
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + orientation.hashCode()
result = 31 * result + overscrollEffect.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + reverseDirection.hashCode()
result = 31 * result + flingBehavior.hashCode()
result = 31 * result + interactionSource.hashCode()
result = 31 * result + bringIntoViewSpec.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ScrollableElement) return false
if (state != other.state) return false
if (orientation != other.orientation) return false
if (overscrollEffect != other.overscrollEffect) 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
if (bringIntoViewSpec != other.bringIntoViewSpec) return false
return true
}
override fun InspectorInfo.inspectableProperties() {
name = "scrollable"
properties["orientation"] = orientation
properties["state"] = state
properties["overscrollEffect"] = overscrollEffect
properties["enabled"] = enabled
properties["reverseDirection"] = reverseDirection
properties["flingBehavior"] = flingBehavior
properties["interactionSource"] = interactionSource
properties["bringIntoViewSpec"] = bringIntoViewSpec
}
}
@OptIn(ExperimentalFoundationApi::class)
private class ScrollableNode(
state: ScrollableState,
private var overscrollEffect: OverscrollEffect?,
private var flingBehavior: FlingBehavior?,
orientation: Orientation,
enabled: Boolean,
reverseDirection: Boolean,
interactionSource: MutableInteractionSource?,
bringIntoViewSpec: BringIntoViewSpec
) : DragGestureNode(
canDrag = CanDragCalculation,
enabled = enabled,
interactionSource = interactionSource
), ObserverModifierNode, CompositionLocalConsumerModifierNode,
FocusPropertiesModifierNode, KeyInputModifierNode {
private val nestedScrollDispatcher = NestedScrollDispatcher()
private val scrollableContainerNode =
delegate(ScrollableContainerNode(enabled))
// Place holder fling behavior, we'll initialize it when the density is available.
private val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))
private val scrollingLogic = ScrollingLogic(
scrollableState = state,
orientation = orientation,
overscrollEffect = overscrollEffect,
reverseDirection = reverseDirection,
flingBehavior = flingBehavior ?: defaultFlingBehavior,
nestedScrollDispatcher = nestedScrollDispatcher,
)
private val nestedScrollConnection =
ScrollableNestedScrollConnection(enabled = enabled, scrollingLogic = scrollingLogic)
private val contentInViewNode =
delegate(
ContentInViewNode(
orientation,
state,
reverseDirection,
bringIntoViewSpec
)
)
// Need to wait until onAttach to read the scroll config. Currently this is static, so we
// don't need to worry about observation / updating this over time.
private var scrollConfig: ScrollConfig? = null
init {
/**
* Nested scrolling
*/
delegate(nestedScrollModifierNode(nestedScrollConnection, nestedScrollDispatcher))
/**
* Focus scrolling
*/
delegate(FocusTargetModifierNode())
delegate(BringIntoViewResponderNode(contentInViewNode))
delegate(FocusedBoundsObserverNode { contentInViewNode.onFocusBoundsChanged(it) })
}
override suspend fun drag(
forEachDelta: suspend ((dragDelta: DragEvent.DragDelta) -> Unit) -> Unit
) {
scrollingLogic.dispatchDragEvents(forEachDelta)
}
override val pointerDirectionConfig: PointerDirectionConfig
get() = scrollingLogic.pointerDirectionConfig()
override suspend fun CoroutineScope.onDragStarted(startedPosition: Offset) {}
override suspend fun CoroutineScope.onDragStopped(velocity: Velocity) {
nestedScrollDispatcher.coroutineScope.launch {
scrollingLogic.onDragStopped(velocity)
}
}
override fun startDragImmediately(): Boolean {
return scrollingLogic.shouldScrollImmediately()
}
fun update(
state: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?,
enabled: Boolean,
reverseDirection: Boolean,
flingBehavior: FlingBehavior?,
interactionSource: MutableInteractionSource?,
bringIntoViewSpec: BringIntoViewSpec
) {
if (this.enabled != enabled) { // enabled changed
nestedScrollConnection.enabled = enabled
scrollableContainerNode.update(enabled)
}
// a new fling behavior was set, change the resolved one.
val resolvedFlingBehavior = flingBehavior ?: defaultFlingBehavior
val resetPointerInputHandling = scrollingLogic.update(
scrollableState = state,
orientation = orientation,
overscrollEffect = overscrollEffect,
reverseDirection = reverseDirection,
flingBehavior = resolvedFlingBehavior,
nestedScrollDispatcher = nestedScrollDispatcher
)
contentInViewNode.update(
orientation,
state,
reverseDirection,
bringIntoViewSpec
)
this.overscrollEffect = overscrollEffect
this.flingBehavior = flingBehavior
// update DragGestureNode
update(CanDragCalculation, enabled, interactionSource, resetPointerInputHandling)
}
override fun onAttach() {
updateDefaultFlingBehavior()
scrollConfig = platformScrollConfig()
}
override fun onObservedReadsChanged() {
// if density changes, update the default fling behavior.
updateDefaultFlingBehavior()
}
private fun updateDefaultFlingBehavior() {
// monitor change in Density
observeReads {
val density = currentValueOf(LocalDensity)
defaultFlingBehavior.flingDecay = splineBasedDecay(density)
}
}
override fun applyFocusProperties(focusProperties: FocusProperties) {
focusProperties.canFocus = false
}
// Key handler for Page up/down scrolling behavior.
override fun onKeyEvent(event: KeyEvent): Boolean {
return if (enabled &&
(event.key == Key.PageDown || event.key == Key.PageUp) &&
(event.type == KeyEventType.KeyDown) &&
(!event.isCtrlPressed)
) {
val scrollAmount: Offset = if (scrollingLogic.isVertical()) {
val viewportHeight = contentInViewNode.viewportSize.height
val yAmount = if (event.key == Key.PageUp) {
viewportHeight.toFloat()
} else {
-viewportHeight.toFloat()
}
Offset(0f, yAmount)
} else {
val viewportWidth = contentInViewNode.viewportSize.width
val xAmount = if (event.key == Key.PageUp) {
viewportWidth.toFloat()
} else {
-viewportWidth.toFloat()
}
Offset(xAmount, 0f)
}
// A coroutine is launched for every individual scroll event in the
// larger scroll gesture. If we see degradation in the future (that is,
// a fast scroll gesture on a slow device causes UI jank [not seen up to
// this point), we can switch to a more efficient solution where we
// lazily launch one coroutine (with the first event) and use a Channel
// to communicate the scroll amount to the UI thread.
coroutineScope.launch {
scrollingLogic.dispatchUserInputDelta(scrollAmount, Wheel)
}
true
} else {
false
}
}
override fun onPreKeyEvent(event: KeyEvent) = false
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
super.onPointerEvent(pointerEvent, pass, bounds)
if (pass == PointerEventPass.Main && pointerEvent.type == PointerEventType.Scroll) {
processMouseWheelEvent(pointerEvent, bounds)
}
}
/**
* Mouse wheel
*/
private fun processMouseWheelEvent(event: PointerEvent, size: IntSize) {
if (event.changes.fastAll { !it.isConsumed }) {
with(scrollConfig!!) {
val scrollAmount = requireDensity().calculateMouseWheelScroll(event, size)
// A coroutine is launched for every individual scroll event in the
// larger scroll gesture. If we see degradation in the future (that is,
// a fast scroll gesture on a slow device causes UI jank [not seen up to
// this point), we can switch to a more efficient solution where we
// lazily launch one coroutine (with the first event) and use a Channel
// to communicate the scroll amount to the UI thread.
coroutineScope.launch {
scrollingLogic.dispatchUserInputDelta(scrollAmount, Wheel)
}
event.changes.fastForEach { it.consume() }
}
}
}
}
/**
* The configuration of how a scrollable reacts to bring into view requests.
*
* Note: API shape and naming are still being refined, therefore API is marked as experimental.
*/
@ExperimentalFoundationApi
@Stable
interface BringIntoViewSpec {
/**
* A retargetable Animation Spec to be used as the animation to run to fulfill the
* BringIntoView requests.
*/
val scrollAnimationSpec: AnimationSpec<Float> get() = DefaultScrollAnimationSpec
/**
* Calculate the offset needed to bring one of the scrollable container's child into view.
*
* @param offset from the side closest to the origin (For the x-axis this is 'left',
* for the y-axis this is 'top').
* @param size is the child size.
* @param containerSize Is the main axis size of the scrollable container.
*
* All distances above are represented in pixels.
*
* @return The necessary amount to scroll to satisfy the bring into view request.
* Returning zero from here means that the request was satisfied and the scrolling animation
* should stop.
*
* This will be called for every frame of the scrolling animation. This means that, as the
* animation progresses, the offset will naturally change to fulfill the scroll request.
*/
fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float
): Float
companion object {
/**
* The default animation spec used by [Modifier.scrollable] to run Bring Into View requests.
*/
val DefaultScrollAnimationSpec: AnimationSpec<Float> = spring()
internal val DefaultBringIntoViewSpec = object : BringIntoViewSpec {
override val scrollAnimationSpec: AnimationSpec<Float> = DefaultScrollAnimationSpec
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float
): Float {
val trailingEdge = offset + size
@Suppress("UnnecessaryVariable") val leadingEdge = offset
return when {
// If the item is already visible, no need to scroll.
leadingEdge >= 0 && trailingEdge <= containerSize -> 0f
// If the item is visible but larger than the parent, we don't scroll.
leadingEdge < 0 && trailingEdge > containerSize -> 0f
// Find the minimum scroll needed to make one of the edges coincide with the parent's
// edge.
abs(leadingEdge) < abs(trailingEdge - containerSize) -> leadingEdge
else -> trailingEdge - containerSize
}
}
}
}
}
/**
* Contains the default values used by [scrollable]
*/
object ScrollableDefaults {
/**
* Create and remember default [FlingBehavior] that will represent natural fling curve.
*/
@Composable
fun flingBehavior(): FlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
DefaultFlingBehavior(flingSpec)
}
}
/**
* Create and remember default [OverscrollEffect] that will be used for showing over scroll
* effects.
*/
@Composable
@ExperimentalFoundationApi
fun overscrollEffect(): OverscrollEffect {
return rememberOverscrollEffect()
}
/**
* Used to determine the value of `reverseDirection` parameter of [Modifier.scrollable]
* in scrollable layouts.
*
* @param layoutDirection current layout direction (e.g. from [LocalLayoutDirection])
* @param orientation orientation of scroll
* @param reverseScrolling whether scrolling direction should be reversed
*
* @return `true` if scroll direction should be reversed, `false` otherwise.
*/
fun reverseDirection(
layoutDirection: LayoutDirection,
orientation: Orientation,
reverseScrolling: Boolean
): Boolean {
// A finger moves with the content, not with the viewport. Therefore,
// always reverse once to have "natural" gesture that goes reversed to layout
var reverseDirection = !reverseScrolling
// But if rtl and horizontal, things move the other way around
val isRtl = layoutDirection == LayoutDirection.Rtl
if (isRtl && orientation != Orientation.Vertical) {
reverseDirection = !reverseDirection
}
return reverseDirection
}
/**
* A default implementation for [BringIntoViewSpec] that brings a child into view
* using the least amount of effort.
*/
@ExperimentalFoundationApi
fun bringIntoViewSpec(): BringIntoViewSpec = DefaultBringIntoViewSpec
}
internal interface ScrollConfig {
fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset
}
internal expect fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig
private val CanDragCalculation: (PointerInputChange) -> Boolean =
{ down -> down.type != PointerType.Mouse }
/**
* Holds all scrolling related logic: controls nested scrolling, flinging, overscroll and delta
* dispatching.
*/
@OptIn(ExperimentalFoundationApi::class)
private class ScrollingLogic(
private var scrollableState: ScrollableState,
private var overscrollEffect: OverscrollEffect?,
private var flingBehavior: FlingBehavior,
private var orientation: Orientation,
private var reverseDirection: Boolean,
private var nestedScrollDispatcher: NestedScrollDispatcher,
) {
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 Velocity.toFloat(): Float =
if (orientation == Horizontal) this.x else this.y
fun Velocity.singleAxisVelocity(): Velocity =
if (orientation == Horizontal) copy(y = 0f) else copy(x = 0f)
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
fun Float.toVelocity() = Velocity(
x = if (orientation == Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
private var latestScrollScope: ScrollScope = NoOpScrollScope
private var latestScrollSource: NestedScrollSource = Drag
private val performScroll: (delta: Offset) -> Offset = { delta ->
val consumedByPreScroll =
nestedScrollDispatcher.dispatchPreScroll(delta, latestScrollSource)
val scrollAvailableAfterPreScroll = delta - consumedByPreScroll
val singleAxisDeltaForSelfScroll =
scrollAvailableAfterPreScroll.singleAxisOffset().reverseIfNeeded().toFloat()
// Consume on a single axis.
val consumedBySelfScroll =
with(latestScrollScope) {
scrollBy(singleAxisDeltaForSelfScroll).toOffset().reverseIfNeeded()
}
val deltaAvailableAfterScroll = scrollAvailableAfterPreScroll - consumedBySelfScroll
val consumedByPostScroll = nestedScrollDispatcher.dispatchPostScroll(
consumedBySelfScroll,
deltaAvailableAfterScroll,
latestScrollSource
)
consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll
}
/**
* @return the amount of scroll that was consumed
*/
private fun ScrollScope.dispatchScroll(
initialAvailableDelta: Offset,
source: NestedScrollSource
): Offset {
latestScrollSource = source
latestScrollScope = this
val overscroll = overscrollEffect
return if (source == Wheel) {
performScroll(initialAvailableDelta)
} else if (overscroll != null && shouldDispatchOverscroll) {
overscroll.applyToScroll(initialAvailableDelta, source, performScroll)
} else {
performScroll(initialAvailableDelta)
}
}
private val shouldDispatchOverscroll
get() = scrollableState.canScrollForward || scrollableState.canScrollBackward
fun performRawScroll(scroll: Offset): Offset {
return if (scrollableState.isScrollInProgress) {
Offset.Zero
} else {
scrollableState.dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
.reverseIfNeeded().toOffset()
}
}
suspend fun onDragStopped(initialVelocity: Velocity) {
val availableVelocity = initialVelocity.singleAxisVelocity()
val performFling: suspend (Velocity) -> Velocity = { velocity ->
val preConsumedByParent = nestedScrollDispatcher
.dispatchPreFling(velocity)
val available = velocity - preConsumedByParent
val velocityLeft = doFlingAnimation(available)
val consumedPost =
nestedScrollDispatcher.dispatchPostFling(
(available - velocityLeft),
velocityLeft
)
val totalLeft = velocityLeft - consumedPost
velocity - totalLeft
}
val overscroll = overscrollEffect
if (overscroll != null && shouldDispatchOverscroll) {
overscroll.applyToFling(availableVelocity, performFling)
} else {
performFling(availableVelocity)
}
}
suspend fun doFlingAnimation(available: Velocity): Velocity {
var result: Velocity = available
scrollableState.scroll {
val outerScopeScroll: (Offset) -> Offset = { delta ->
dispatchScroll(delta.reverseIfNeeded(), Fling).reverseIfNeeded()
}
val scope = object : ScrollScope {
override fun scrollBy(pixels: Float): Float {
return outerScopeScroll.invoke(pixels.toOffset()).toFloat()
}
}
with(scope) {
with(flingBehavior) {
result = result.update(
performFling(available.toFloat().reverseIfNeeded()).reverseIfNeeded()
)
}
}
}
return result
}
fun shouldScrollImmediately(): Boolean {
return scrollableState.isScrollInProgress ||
overscrollEffect?.isInProgress ?: false
}
suspend fun dispatchUserInputDelta(delta: Offset, source: NestedScrollSource) {
scrollableState.scroll(MutatePriority.UserInput) {
dispatchScroll(delta, source)
}
}
suspend fun dispatchDragEvents(
forEachDelta: suspend ((dragDelta: DragEvent.DragDelta) -> Unit) -> Unit
) {
scrollableState.scroll(MutatePriority.UserInput) {
forEachDelta {
dispatchScroll(it.delta.singleAxisOffset(), Drag)
}
}
}
/**
* @return true if the pointer input should be reset
*/
fun update(
scrollableState: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?,
reverseDirection: Boolean,
flingBehavior: FlingBehavior,
nestedScrollDispatcher: NestedScrollDispatcher,
): Boolean {
var resetPointerInputHandling = false
if (this.scrollableState != scrollableState) {
this.scrollableState = scrollableState
resetPointerInputHandling = true
}
this.overscrollEffect = overscrollEffect
if (this.orientation != orientation) {
this.orientation = orientation
resetPointerInputHandling = true
}
if (this.reverseDirection != reverseDirection) {
this.reverseDirection = reverseDirection
resetPointerInputHandling = true
}
this.flingBehavior = flingBehavior
this.nestedScrollDispatcher = nestedScrollDispatcher
return resetPointerInputHandling
}
fun pointerDirectionConfig(): PointerDirectionConfig = orientation.toPointerDirectionConfig()
fun isVertical(): Boolean = orientation == Vertical
}
private val NoOpScrollScope: ScrollScope = object : ScrollScope {
override fun scrollBy(pixels: Float): Float = pixels
}
private class ScrollableNestedScrollConnection(
val scrollingLogic: ScrollingLogic,
var enabled: Boolean
) : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = if (enabled) {
scrollingLogic.performRawScroll(available)
} else {
Offset.Zero
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
return if (enabled) {
val velocityLeft = scrollingLogic.doFlingAnimation(available)
available - velocityLeft
} else {
Velocity.Zero
}
}
}
internal class DefaultFlingBehavior(
var flingDecay: DecayAnimationSpec<Float>,
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 (exception: CancellationException) {
velocityLeft = animationState.velocity
}
velocityLeft
} else {
initialVelocity
}
}
}
}
private const val DefaultScrollMotionDurationScaleFactor = 1f
internal val DefaultScrollMotionDurationScale = object : MotionDurationScale {
override val scaleFactor: Float
get() = DefaultScrollMotionDurationScaleFactor
}
/**
* (b/311181532): This could not be flattened so we moved it to TraversableNode, but ideally
* ScrollabeNode should be the one to be travesable.
*/
internal class ScrollableContainerNode(enabled: Boolean) :
Modifier.Node(),
TraversableNode {
override val traverseKey: Any = TraverseKey
var enabled: Boolean = enabled
private set
companion object TraverseKey
fun update(enabled: Boolean) {
this.enabled = enabled
}
}
private val UnityDensity = object : Density {
override val density: Float
get() = 1f
override val fontScale: Float
get() = 1f
}