diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt index e4c60e166fd579bfd092930ea692f73bfe5b48ca..5cb45e5bd914e6e63950327c36b1c9dd2ca877b6 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt @@ -18,9 +18,11 @@ package com.android.systemui.notifications.ui.composable import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceAtMost import com.android.compose.nestedscroll.PriorityNestedScrollConnection +import com.android.compose.nestedscroll.ScrollController /** * A [NestedScrollConnection] that listens for all vertical scroll events and responds in the @@ -58,33 +60,40 @@ fun NotificationScrimNestedScrollConnection( offsetAvailable > 0 && (scrimOffset() < maxScrimOffset || isCurrentGestureOverscroll()) }, canStartPostFling = { false }, - canStopOnPreFling = { false }, - onStart = { offsetAvailable -> onStart(offsetAvailable) }, - onScroll = { offsetAvailable, _ -> - val currentHeight = scrimOffset() - val amountConsumed = - if (offsetAvailable > 0) { - val amountLeft = maxScrimOffset - currentHeight - offsetAvailable.fastCoerceAtMost(amountLeft) - } else { - val amountLeft = minScrimOffset() - currentHeight - offsetAvailable.fastCoerceAtLeast(amountLeft) + onStart = { firstScroll -> + onStart(firstScroll) + object : ScrollController { + override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float { + val currentHeight = scrimOffset() + val amountConsumed = + if (deltaScroll > 0) { + val amountLeft = maxScrimOffset - currentHeight + deltaScroll.fastCoerceAtMost(amountLeft) + } else { + val amountLeft = minScrimOffset() - currentHeight + deltaScroll.fastCoerceAtLeast(amountLeft) + } + snapScrimOffset(currentHeight + amountConsumed) + return amountConsumed } - snapScrimOffset(currentHeight + amountConsumed) - amountConsumed - }, - onStop = { velocityAvailable -> - onStop(velocityAvailable) - if (scrimOffset() < minScrimOffset()) { - animateScrimOffset(minScrimOffset()) - } - // Don't consume the velocity on pre/post fling - 0f - }, - onCancel = { - onStop(0f) - if (scrimOffset() < minScrimOffset()) { - animateScrimOffset(minScrimOffset()) + + override suspend fun onStop(initialVelocity: Float): Float { + onStop(initialVelocity) + if (scrimOffset() < minScrimOffset()) { + animateScrimOffset(minScrimOffset()) + } + // Don't consume the velocity on pre/post fling + return 0f + } + + override fun onCancel() { + onStop(0f) + if (scrimOffset() < minScrimOffset()) { + animateScrimOffset(minScrimOffset()) + } + } + + override fun canStopOnPreFling() = false } }, ) diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationStackNestedScrollConnection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationStackNestedScrollConnection.kt index edb05ebd77d1bd2cde2caec37ab802219b4ef869..e1b74a968caab2bbd788b874fb5138e1930334c4 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationStackNestedScrollConnection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationStackNestedScrollConnection.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity @@ -30,6 +31,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtLeast import com.android.compose.nestedscroll.PriorityNestedScrollConnection +import com.android.compose.nestedscroll.ScrollController import kotlin.math.max import kotlin.math.roundToInt import kotlin.math.tanh @@ -92,20 +94,29 @@ fun NotificationStackNestedScrollConnection( offsetAvailable < 0f && offsetBeforeStart < 0f && !canScrollForward() }, canStartPostFling = { velocityAvailable -> velocityAvailable < 0f && !canScrollForward() }, - canStopOnPreFling = { false }, - onStart = { offsetAvailable -> onStart(offsetAvailable) }, - onScroll = { offsetAvailable, _ -> - val minOffset = 0f - val consumed = offsetAvailable.fastCoerceAtLeast(minOffset - stackOffset()) - if (consumed != 0f) { - onScroll(consumed) + onStart = { firstScroll -> + onStart(firstScroll) + object : ScrollController { + override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float { + val minOffset = 0f + val consumed = deltaScroll.fastCoerceAtLeast(minOffset - stackOffset()) + if (consumed != 0f) { + onScroll(consumed) + } + return consumed + } + + override suspend fun onStop(initialVelocity: Float): Float { + onStop(initialVelocity) + return initialVelocity + } + + override fun onCancel() { + onStop(0f) + } + + override fun canStopOnPreFling() = false } - consumed - }, - onStop = { velocityAvailable -> - onStop(velocityAvailable) - velocityAvailable }, - onCancel = { onStop(0f) }, ) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt index 8469007eddb6624f418d9c28c6b70513c44a7d9e..7c7202a5c7f22de9e81df1c02ec98484c47002f1 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt @@ -20,6 +20,7 @@ package com.android.compose.animation.scene import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.util.fastCoerceIn @@ -27,6 +28,7 @@ import com.android.compose.animation.scene.content.Content import com.android.compose.animation.scene.content.state.TransitionState import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified import com.android.compose.nestedscroll.PriorityNestedScrollConnection +import com.android.compose.nestedscroll.ScrollController import kotlin.math.absoluteValue internal typealias SuspendedValue = suspend () -> T @@ -66,6 +68,7 @@ internal class DraggableHandlerImpl( internal val orientation: Orientation, ) : DraggableHandler { internal val nestedScrollKey = Any() + /** The [DraggableHandler] can only have one active [DragController] at a time. */ private var dragController: DragControllerImpl? = null @@ -345,6 +348,7 @@ private class DragControllerImpl( distance == DistanceUnspecified || swipeAnimation.contentTransition.isWithinProgressRange(desiredProgress) -> desiredOffset + distance > 0f -> desiredOffset.fastCoerceIn(0f, distance) else -> desiredOffset.fastCoerceIn(distance, 0f) } @@ -545,6 +549,7 @@ internal class Swipes( upOrLeftResult == null && downOrRightResult == null -> null (directionOffset < 0f && upOrLeftResult != null) || downOrRightResult == null -> upOrLeftResult + else -> downOrRightResult } } @@ -608,7 +613,6 @@ internal class NestedScrollHandlerImpl( return overscrollSpec != null } - var dragController: DragController? = null var isIntercepting = false return PriorityNestedScrollConnection( @@ -669,10 +673,12 @@ internal class NestedScrollHandlerImpl( canChangeScene = isZeroOffset isZeroOffset && hasNextScene(offsetAvailable) } + NestedScrollBehavior.EdgeWithPreview -> { canChangeScene = isZeroOffset hasNextScene(offsetAvailable) } + NestedScrollBehavior.EdgeAlways -> { canChangeScene = true hasNextScene(offsetAvailable) @@ -710,53 +716,56 @@ internal class NestedScrollHandlerImpl( canStart }, - // We need to maintain scroll priority even if the scene transition can no longer - // consume the scroll gesture to allow us to return to the previous scene. - canStopOnScroll = { _, _ -> false }, - canStopOnPreFling = { true }, - onStart = { offsetAvailable -> + onStart = { firstScroll -> val pointersInfo = pointersInfo() - dragController = - draggableHandler.onDragStarted( - pointersDown = pointersInfo.pointersDown, - startedPosition = pointersInfo.startedPosition, - overSlop = if (isIntercepting) 0f else offsetAvailable, - ) + scrollController( + dragController = + draggableHandler.onDragStarted( + pointersDown = pointersInfo.pointersDown, + startedPosition = pointersInfo.startedPosition, + overSlop = if (isIntercepting) 0f else firstScroll, + ), + canChangeScene = canChangeScene, + pointersInfoOwner = pointersInfoOwner, + ) }, - onScroll = { offsetAvailable, _ -> - val controller = dragController ?: error("Should be called after onStart") + ) + } +} - val pointersInfo = pointersInfoOwner.pointersInfo() - if (pointersInfo.isMouseWheel) { - // Do not support mouse wheel interactions - return@PriorityNestedScrollConnection 0f - } +private fun scrollController( + dragController: DragController, + canChangeScene: Boolean, + pointersInfoOwner: PointersInfoOwner, +): ScrollController { + return object : ScrollController { + override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float { + val pointersInfo = pointersInfoOwner.pointersInfo() + if (pointersInfo.isMouseWheel) { + // Do not support mouse wheel interactions + return 0f + } - // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture is - // initiated in a nested child. - controller.onDrag(delta = offsetAvailable) - }, - onStop = { velocityAvailable -> - val controller = dragController ?: error("Should be called after onStart") - try { - controller - .onStop(velocity = velocityAvailable, canChangeContent = canChangeScene) - .invoke() - } finally { - // onStop might still be running when a new gesture begins. - // To prevent conflicts, we should only remove the drag controller if it's the - // same one that was active initially. - if (dragController == controller) { - dragController = null - } - } - }, - onCancel = { - val controller = dragController ?: error("Should be called after onStart") - controller.onStop(velocity = 0f, canChangeContent = canChangeScene) - dragController = null - }, - ) + return dragController.onDrag(delta = deltaScroll) + } + + override suspend fun onStop(initialVelocity: Float): Float { + return dragController + .onStop(velocity = initialVelocity, canChangeContent = canChangeScene) + .invoke() + } + + override fun onCancel() { + dragController.onStop(velocity = 0f, canChangeContent = canChangeScene) + } + + /** + * We need to maintain scroll priority even if the scene transition can no longer consume + * the scroll gesture to allow us to return to the previous scene. + */ + override fun canCancelScroll(available: Float, consumed: Float) = false + + override fun canStopOnPreFling() = true } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt index ecf64b771d1f41c76ed3cab9d1980b160b71baed..255da31719f3e523f0d6b62ce3c55db637b963d2 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/LargeTopAppBarNestedScrollConnection.kt @@ -18,6 +18,7 @@ package com.android.compose.nestedscroll import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceAtMost @@ -54,23 +55,38 @@ fun LargeTopAppBarNestedScrollConnection( offsetAvailable > 0 && height() < maxHeight() }, canStartPostFling = { false }, - canStopOnPreFling = { false }, - onStart = { /* do nothing */ }, - onScroll = { offsetAvailable, _ -> - val currentHeight = height() - val amountConsumed = - if (offsetAvailable > 0) { - val amountLeft = maxHeight() - currentHeight - offsetAvailable.fastCoerceAtMost(amountLeft) - } else { - val amountLeft = minHeight() - currentHeight - offsetAvailable.fastCoerceAtLeast(amountLeft) - } - onHeightChanged(currentHeight + amountConsumed) - amountConsumed - }, - // Don't consume the velocity on pre/post fling - onStop = { 0f }, - onCancel = { /* do nothing */ }, + onStart = { LargeTopAppBarScrollController(height, maxHeight, minHeight, onHeightChanged) }, ) } + +private class LargeTopAppBarScrollController( + val height: () -> Float, + val maxHeight: () -> Float, + val minHeight: () -> Float, + val onHeightChanged: (Float) -> Unit, +) : ScrollController { + override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float { + val currentHeight = height() + val amountConsumed = + if (deltaScroll > 0) { + val amountLeft = maxHeight() - currentHeight + deltaScroll.fastCoerceAtMost(amountLeft) + } else { + val amountLeft = minHeight() - currentHeight + deltaScroll.fastCoerceAtLeast(amountLeft) + } + onHeightChanged(currentHeight + amountConsumed) + return amountConsumed + } + + override suspend fun onStop(initialVelocity: Float): Float { + // Don't consume the velocity on pre/post fling + return 0f + } + + override fun onCancel() { + // do nothing + } + + override fun canStopOnPreFling() = false +} diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt index 57d236be40cea38f44a54f44f6602d04ee6195e9..ca44a5c21cab96b12d5c99e7c0a3b9eeb69bb823 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt @@ -16,46 +16,106 @@ package com.android.compose.nestedscroll -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.DecayAnimationSpec -import androidx.compose.animation.core.animateDecay import androidx.compose.foundation.gestures.Orientation 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 com.android.compose.ui.util.SpaceVectorConverter -import kotlin.math.abs import kotlin.math.sign -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope /** - * A [NestedScrollConnection] that intercepts scroll events in priority mode. + * The [ScrollController] provides control over the scroll gesture. It allows you to: + * - Scroll the content by a given pixel amount. + * - Cancel the current scroll operation. + * - Stop the scrolling with a given initial velocity. * - * Priority mode allows this connection to take control over scroll events within a nested scroll - * hierarchy. When in priority mode, this connection consumes scroll events before its children, - * enabling custom scrolling behaviors like sticky headers. + * **Important Notes:** + * - [onCancel] is called only when [PriorityNestedScrollConnection.reset] is invoked or when + * [canCancelScroll] returns `true` after a call to [onScroll]. It is never called after [onStop]. + * - [onStop] can be interrupted by a new gesture. In such cases, you need to handle a potential + * cancellation within your implementation of [onStop], although [onCancel] will not be called. + */ +interface ScrollController { + /** + * Scrolls the current content by [deltaScroll] pixels. + * + * @param deltaScroll The amount of pixels to scroll by. + * @param source The source of the scroll event. + * @return The amount of [deltaScroll] that was consumed. + */ + fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float + + /** + * Checks if the current scroll operation can be canceled. This is typically called after + * [onScroll] to determine if the [ScrollController] has lost priority and should cancel the + * ongoing scroll operation. + * + * @param available The total amount of scroll available. + * @param consumed The amount of scroll consumed by [onScroll]. + * @return `true` if the scroll can be canceled. + */ + fun canCancelScroll(available: Float, consumed: Float): Boolean { + return consumed == 0f + } + + /** + * Cancels the current scroll operation. This method is called when + * [PriorityNestedScrollConnection.reset] is invoked or when [canCancelScroll] returns `true`. + */ + fun onCancel() + + /** + * Checks if the scroll can be stopped during the [NestedScrollConnection.onPreFling] phase. + * + * @return `true` if the scroll can be stopped. + */ + fun canStopOnPreFling(): Boolean + + /** + * Stops the controller with the given [initialVelocity]. This typically starts a decay + * animation to smoothly bring the scrolling to a stop. This method can be interrupted by a new + * gesture, requiring you to handle potential cancellation within your implementation. + * + * @param initialVelocity The initial velocity of the scroll when stopping. + * @return The consumed [initialVelocity] when the animation completes. + */ + suspend fun onStop(initialVelocity: Float): Float +} + +/** + * A [NestedScrollConnection] that lets you implement custom scroll behaviors that take priority + * over the default nested scrolling logic. + * + * When started, this connection intercepts scroll events *before* they reach child composables. + * This "priority mode" is activated activated when either [canStartPreScroll], [canStartPostScroll] + * or [canStartPostFling] returns `true`. + * + * Once started, the [onStart] lambda provides a [ScrollController] to manage the scrolling. This + * controller allows you to directly manipulate the scroll state and define how scroll events are + * consumed. + * + * **Important Considerations:** + * - When started, scroll events are typically consumed in `onPreScroll`. + * - The provided [ScrollController] should handle potential cancellation of `onStop` due to new + * gestures. + * - Use [reset] to release the current [ScrollController] and reset the connection to its initial + * state. * * @param orientation The orientation of the scroll. - * @param canStartPreScroll lambda that returns true if the connection can start consuming scroll - * events in pre-scroll mode. - * @param canStartPostScroll lambda that returns true if the connection can start consuming scroll - * events in post-scroll mode. - * @param canStartPostFling lambda that returns true if the connection can start consuming scroll - * events in post-fling mode. - * @param canStopOnScroll lambda that returns true if the connection can stop consuming scroll - * events in scroll mode. - * @param canStopOnPreFling lambda that returns true if the connection can stop consuming scroll - * events in pre-fling (i.e. as soon as the user lifts their fingers). - * @param onStart lambda that is called when the connection starts consuming scroll events. - * @param onScroll lambda that is called when the connection consumes a scroll event and returns the - * consumed amount. - * @param onStop lambda that is called when the connection stops consuming scroll events and returns - * the consumed velocity. - * @param onCancel lambda that is called when the connection is cancelled. + * @param canStartPreScroll A lambda that returns `true` if the connection should enter priority + * mode during the pre-scroll phase. This is called before child connections have a chance to + * consume the scroll. + * @param canStartPostScroll A lambda that returns `true` if the connection should enter priority + * mode during the post-scroll phase. This is called after child connections have consumed the + * scroll. + * @param canStartPostFling A lambda that returns `true` if the connection should enter priority + * mode during the post-fling phase. This is called after a fling gesture has been initiated. + * @param onStart A lambda that is called when the connection enters priority mode. It should return + * a [ScrollController] that will be used to control the scroll. * @sample LargeTopAppBarNestedScrollConnection * @sample com.android.compose.animation.scene.NestedScrollHandlerImpl.nestedScrollConnection */ @@ -66,169 +126,213 @@ class PriorityNestedScrollConnection( private val canStartPostScroll: (offsetAvailable: Float, offsetBeforeStart: Float, source: NestedScrollSource) -> Boolean, private val canStartPostFling: (velocityAvailable: Float) -> Boolean, - private val canStopOnScroll: (available: Float, consumed: Float) -> Boolean = { _, consumed -> - consumed == 0f - }, - private val canStopOnPreFling: () -> Boolean, - private val onStart: (offsetAvailable: Float) -> Unit, - private val onScroll: (offsetAvailable: Float, source: NestedScrollSource) -> Float, - private val onStop: suspend (velocityAvailable: Float) -> Float, - private val onCancel: () -> Unit, + private val onStart: (firstScroll: Float) -> ScrollController, ) : NestedScrollConnection, SpaceVectorConverter by SpaceVectorConverter(orientation) { - /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */ - private var isPriorityMode = false + /** The currently active [ScrollController], or `null` if not in priority mode. */ + private var currentController: ScrollController? = null + + /** + * A [Deferred] representing the ongoing `onStop` animation. Used to interrupt the animation if + * a new gesture occurs. + */ + private var stoppingJob: Deferred? = null + /** + * Indicates whether the connection is currently in the process of stopping the scroll with the + * [ScrollController.onStop] animation. + */ + private val isStopping + get() = stoppingJob?.isActive ?: false + + /** + * Tracks the cumulative scroll offset that has been consumed by other composables before this + * connection enters priority mode. This is used to determine when the connection should take + * over scrolling based on the [canStartPreScroll] and [canStartPostScroll] conditions. + */ private var offsetScrolledBeforePriorityMode = 0f - /** This job allows us to interrupt the onStop animation */ - private var onStopJob: Deferred = CompletableDeferred(0f) + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // If stopping, interrupt the animation and clear the controller. + if (isStopping) { + interruptStopping() + } + + // If in priority mode, consume the scroll using the current controller. + if (currentController != null) { + return scroll(available.toFloat(), source) + } + + // Check if pre-scroll condition is met, and start priority mode if necessary. + val availableFloat = available.toFloat() + if (canStartPreScroll(availableFloat, offsetScrolledBeforePriorityMode, source)) { + start(availableFloat) + return scroll(availableFloat, source) + } + + // Track offset consumed before entering priority mode. + offsetScrolledBeforePriorityMode += availableFloat + return Offset.Zero + } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { + // If in priority mode, scroll events are consumed only in pre-scroll phase. + if (currentController != null) return Offset.Zero + + // Check if post-scroll condition is met, and start priority mode if necessary. val availableFloat = available.toFloat() - // The offset before the start takes into account the up and down movements, starting from - // the beginning or from the last fling gesture. val offsetBeforeStart = offsetScrolledBeforePriorityMode - availableFloat - - if (isPriorityMode || !canStartPostScroll(availableFloat, offsetBeforeStart, source)) { - // The priority mode cannot start so we won't consume the available offset. - return Offset.Zero + if (canStartPostScroll(availableFloat, offsetBeforeStart, source)) { + start(availableFloat) + return scroll(availableFloat, source) } - return start(availableFloat, source).toOffset() - } - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (!isPriorityMode) { - val availableFloat = available.toFloat() - if (canStartPreScroll(availableFloat, offsetScrolledBeforePriorityMode, source)) { - return start(availableFloat, source).toOffset() - } - // We want to track the amount of offset consumed before entering priority mode - offsetScrolledBeforePriorityMode += availableFloat - return Offset.Zero - } - - return scroll(available.toFloat(), source).toOffset() + // Do not consume the offset if priority mode is not activated. + return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { - if (!isPriorityMode) { - resetOffsetTracker() - return Velocity.Zero - } + val controller = currentController ?: return Velocity.Zero - if (canStopOnPreFling()) { - // Step 3b: The finger is lifted, we can stop intercepting scroll events and use the - // velocity of the fling gesture. - return stop(velocityAvailable = available.toFloat()).toVelocity() + // If in priority mode and can stop on pre-fling phase, stop the scroll. + if (controller.canStopOnPreFling()) { + return stop(velocity = available.toFloat()) } - // We don't want to consume the velocity, we prefer to continue receiving scroll events. + // Do not consume the velocity if not stopping on pre-fling phase. return Velocity.Zero } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val availableFloat = available.toFloat() - if (isPriorityMode) { - return stop(velocityAvailable = availableFloat).toVelocity() - } + val controller = currentController - if (!canStartPostFling(availableFloat)) { - return Velocity.Zero + // If in priority mode, stop the scroll. + if (controller != null) { + return stop(velocity = availableFloat) } - // The offset passed to onPriorityStart() must be != 0f, so we create a small offset of 1px - // given the available velocity. + // Check if post-fling condition is met, and start priority mode if necessary. // TODO(b/291053278): Remove canStartPostFling() and instead make it possible to define the // overscroll behavior on the Scene level. - val smallOffset = availableFloat.sign - start( - availableOffset = smallOffset, - source = NestedScrollSource.SideEffect, - skipScroll = true, - ) - - // This is the last event of a scroll gesture. - return stop(availableFloat).toVelocity() + if (canStartPostFling(availableFloat)) { + // The offset passed to onPriorityStart() must be != 0f, so we create a small offset of + // 1px given the available velocity. + val smallOffset = availableFloat.sign + start(availableOffset = smallOffset) + return stop(availableFloat) + } + + // Reset offset tracking after the fling gesture is finished. + resetOffsetTracker() + return Velocity.Zero } /** - * Method to call before destroying the object or to reset the initial state. - * - * TODO(b/303224944) This method should be removed. + * Resets the connection to its initial state. This cancels any ongoing scroll operation and + * clears the current [ScrollController]. */ fun reset() { - if (isPriorityMode) { - // Step 3c: To ensure that an onStop (or onCancel) is always called for every onStart. + if (currentController != null && !isStopping) { cancel() } else { resetOffsetTracker() } } - private fun start( - availableOffset: Float, - source: NestedScrollSource, - skipScroll: Boolean = false, - ): Float { - check(!isPriorityMode) { - "This should never happen, start() was called when isPriorityMode" - } - - // Step 1: It's our turn! We start capturing scroll events when one of our children has an - // available offset following a scroll event. - isPriorityMode = true + /** + * Starts priority mode by creating a new [ScrollController] using the [onStart] lambda. + * + * @param availableOffset The initial scroll offset available. + */ + private fun start(availableOffset: Float) { + check(currentController == null) { "Another controller is active: $currentController" } - onStopJob.cancel() + resetOffsetTracker() - // Note: onStop will be called if we cannot continue to scroll (step 3a), or the finger is - // lifted (step 3b), or this object has been destroyed (step 3c). - onStart(availableOffset) + currentController = onStart(availableOffset) + } - return if (skipScroll) 0f else scroll(availableOffset, source) + /** + * Retrieves the current [ScrollController], ensuring that it is not null and that the + * [isStopping] state matches the expected value. + */ + private fun requireController(isStopping: Boolean): ScrollController { + check(this.isStopping == isStopping) { + "isStopping is ${this.isStopping}, instead of $isStopping" + } + check(offsetScrolledBeforePriorityMode == 0f) { + "offset scrolled should be zero, but it was $offsetScrolledBeforePriorityMode" + } + return checkNotNull(currentController) { "The controller is $currentController" } } - private fun scroll(offsetAvailable: Float, source: NestedScrollSource): Float { - // Step 2: We have the priority and can consume the scroll events. - val consumedByScroll = onScroll(offsetAvailable, source) + /** + * Scrolls the content using the current [ScrollController]. + * + * @param delta The amount of scroll to apply. + * @param source The source of the scroll event. + * @return The amount of scroll consumed. + */ + private fun scroll(delta: Float, source: NestedScrollSource): Offset { + val controller = requireController(isStopping = false) + val consumedByScroll = controller.onScroll(delta, source) - if (canStopOnScroll(offsetAvailable, consumedByScroll)) { - // Step 3a: We have lost priority and we no longer need to intercept scroll events. + if (controller.canCancelScroll(delta, consumedByScroll)) { + // We have lost priority and we no longer need to intercept scroll events. cancel() - - // We've just reset offsetScrolledBeforePriorityMode to 0f - // We want to track the amount of offset consumed before entering priority mode - offsetScrolledBeforePriorityMode += offsetAvailable - consumedByScroll + offsetScrolledBeforePriorityMode = delta - consumedByScroll } - return consumedByScroll + return consumedByScroll.toOffset() } - /** Reset the tracking of consumed offsets before entering in priority mode. */ - private fun resetOffsetTracker() { - offsetScrolledBeforePriorityMode = 0f + /** Cancels the current scroll operation and clears the current [ScrollController]. */ + private fun cancel() { + requireController(isStopping = false).onCancel() + currentController = null } - private suspend fun stop(velocityAvailable: Float): Float { - check(isPriorityMode) { "This should never happen, stop() was called before start()" } - isPriorityMode = false - resetOffsetTracker() - + /** + * Stops the scroll with the given velocity using the current [ScrollController]. + * + * @param velocity The velocity to stop with. + * @return The consumed velocity. + */ + suspend fun stop(velocity: Float): Velocity { + val controller = requireController(isStopping = false) return coroutineScope { - onStopJob = async { onStop(velocityAvailable) } - onStopJob.await() + try { + async { controller.onStop(velocity) } + // Allows others to interrupt the job. + .also { stoppingJob = it } + // Note: this can be cancelled by [interruptStopping] + .await() + .toVelocity() + } finally { + // If the job is interrupted, it might take a while to cancel. We need to make sure + // the current controller is still the initial one. + if (currentController == controller) { + currentController = null + } + } } } - private fun cancel() { - check(isPriorityMode) { "This should never happen, cancel() was called before start()" } - isPriorityMode = false - resetOffsetTracker() - onCancel() + /** Interrupts the ongoing stop animation and clears the current [ScrollController]. */ + private fun interruptStopping() { + requireController(isStopping = true) + // We are throwing a CancellationException in the [ScrollController.onStop] method. + stoppingJob?.cancel() + currentController = null + } + + /** Resets the tracking of consumed offsets before entering priority mode. */ + private fun resetOffsetTracker() { + offsetScrolledBeforePriorityMode = 0f } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt index 1a3b86b936df40dc6a65a3e38403412160431636..0364cdc4166e25f7df31e72915db311d8b8e5b55 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt @@ -20,6 +20,7 @@ package com.android.compose.nestedscroll import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.UserInput import androidx.compose.ui.unit.Velocity import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -48,17 +49,26 @@ class PriorityNestedScrollConnectionTest { canStartPreScroll = { _, _, _ -> canStartPreScroll }, canStartPostScroll = { _, _, _ -> canStartPostScroll }, canStartPostFling = { canStartPostFling }, - canStopOnPreFling = { canStopOnPreFling }, - onStart = { isStarted = true }, - onScroll = { offsetAvailable, _ -> - lastScroll = offsetAvailable - if (consumeScroll) offsetAvailable else 0f + onStart = { _ -> + isStarted = true + object : ScrollController { + override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float { + lastScroll = deltaScroll + return if (consumeScroll) deltaScroll else 0f + } + + override suspend fun onStop(initialVelocity: Float): Float { + lastStop = initialVelocity + return if (consumeStop) initialVelocity else 0f + } + + override fun onCancel() { + isCancelled = true + } + + override fun canStopOnPreFling() = canStopOnPreFling + } }, - onStop = { - lastStop = it - if (consumeStop) it else 0f - }, - onCancel = { isCancelled = true }, ) @Test