Merge "STL introduces OverscrollEffects [1/2]" into main
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 caf5e41..2d589f3 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
@@ -27,8 +27,11 @@
import com.android.compose.nestedscroll.OnStopScope
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
import com.android.compose.nestedscroll.ScrollController
+import com.android.compose.ui.util.SpaceVectorConverter
import kotlin.math.absoluteValue
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
internal interface DraggableHandler {
/**
@@ -191,9 +194,15 @@
private val draggableHandler: DraggableHandlerImpl,
val swipes: Swipes,
var swipeAnimation: SwipeAnimation<*>,
-) : DragController {
+) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
val layoutState = draggableHandler.layoutImpl.state
+ val overscrollableContent: OverscrollableContent =
+ when (draggableHandler.orientation) {
+ Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
+ Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
+ }
+
/**
* Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
* nothing.
@@ -224,36 +233,75 @@
* @return the consumed delta
*/
override fun onDrag(delta: Float): Float {
- return onDrag(delta, swipeAnimation)
- }
-
- private fun <T : ContentKey> onDrag(delta: Float, swipeAnimation: SwipeAnimation<T>): Float {
- if (delta == 0f || !isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
+ val initialAnimation = swipeAnimation
+ if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
return 0f
}
+ // swipeAnimation can change during the gesture, we want to always use the initial reference
+ // during the whole drag gesture.
+ return dragWithOverscroll(delta, animation = initialAnimation)
+ }
- val distance = swipeAnimation.distance()
- val previousOffset = swipeAnimation.dragOffset
+ private fun <T : ContentKey> dragWithOverscroll(
+ delta: Float,
+ animation: SwipeAnimation<T>,
+ ): Float {
+ require(delta != 0f) { "delta should not be 0" }
+ var overscrollEffect = overscrollableContent.currentOverscrollEffect
+
+ // If we're already overscrolling, continue with the current effect for a smooth finish.
+ if (overscrollEffect == null || !overscrollEffect.isInProgress) {
+ // Otherwise, determine the target content (toContent or fromContent) for the new
+ // overscroll effect based on the gesture's direction.
+ val content = animation.contentByDirection(delta)
+ overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
+ }
+
+ // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
+ if (!overscrollEffect.node.node.isAttached) {
+ return drag(delta, animation)
+ }
+
+ return overscrollEffect
+ .applyToScroll(
+ delta = delta.toOffset(),
+ source = NestedScrollSource.UserInput,
+ performScroll = {
+ val preScrollAvailable = it.toFloat()
+ drag(preScrollAvailable, animation).toOffset()
+ },
+ )
+ .toFloat()
+ }
+
+ private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
+ if (delta == 0f) return 0f
+
+ val distance = animation.distance()
+ val previousOffset = animation.dragOffset
val desiredOffset = previousOffset + delta
- val desiredProgress = swipeAnimation.computeProgress(desiredOffset)
+ val desiredProgress = animation.computeProgress(desiredOffset)
- // Note: the distance could be negative if fromContent is above or to the left of
- // toContent.
+ // Note: the distance could be negative if fromContent is above or to the left of toContent.
val newOffset =
when {
distance == DistanceUnspecified ||
- swipeAnimation.contentTransition.isWithinProgressRange(desiredProgress) ->
+ animation.contentTransition.isWithinProgressRange(desiredProgress) ->
desiredOffset
distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
else -> desiredOffset.fastCoerceIn(distance, 0f)
}
- swipeAnimation.dragOffset = newOffset
+ animation.dragOffset = newOffset
return newOffset - previousOffset
}
override suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float {
- return onStop(velocity, canChangeContent, swipeAnimation)
+ // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
+ // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
+ // coroutine from being cancelled prematurely, which could interrupt the animation.
+ // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
+ return withContext(NonCancellable) { onStop(velocity, canChangeContent, swipeAnimation) }
}
private suspend fun <T : ContentKey> onStop(
@@ -304,7 +352,22 @@
fromContent
}
- return swipeAnimation.animateOffset(velocity, targetContent)
+ val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)
+
+ // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
+ if (!overscrollEffect.node.node.isAttached) {
+ return swipeAnimation.animateOffset(velocity, targetContent)
+ }
+
+ overscrollEffect.applyToFling(
+ velocity = velocity.toVelocity(),
+ performFling = {
+ val velocityLeft = it.toFloat()
+ swipeAnimation.animateOffset(velocityLeft, targetContent).toVelocity()
+ },
+ )
+
+ return velocity
}
/**
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index a14b2b3..bf7e8e8 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -36,6 +36,7 @@
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
+import com.android.compose.animation.scene.effect.ContentOverscrollEffect
/**
* [SceneTransitionLayout] is a container that automatically animates its content whenever its state
@@ -283,6 +284,53 @@
@ElementDsl
interface ContentScope : BaseContentScope {
/**
+ * The overscroll effect applied to the content in the vertical direction. This can be used to
+ * customize how the content behaves when the scene is over scrolled.
+ *
+ * For example, you can use it with the `Modifier.overscroll()` modifier:
+ * ```kotlin
+ * @Composable
+ * fun ContentScope.MyScene() {
+ * Box(
+ * modifier = Modifier
+ * // Apply the effect
+ * .overscroll(verticalOverscrollEffect)
+ * ) {
+ * // ... your content ...
+ * }
+ * }
+ * ```
+ *
+ * Or you can read the `overscrollDistance` value directly, if you need some custom overscroll
+ * behavior:
+ * ```kotlin
+ * @Composable
+ * fun ContentScope.MyScene() {
+ * Box(
+ * modifier = Modifier
+ * .graphicsLayer {
+ * // Translate half of the overscroll
+ * translationY = verticalOverscrollEffect.overscrollDistance * 0.5f
+ * }
+ * ) {
+ * // ... your content ...
+ * }
+ * }
+ * ```
+ *
+ * @see horizontalOverscrollEffect
+ */
+ val verticalOverscrollEffect: ContentOverscrollEffect
+
+ /**
+ * The overscroll effect applied to the content in the horizontal direction. This can be used to
+ * customize how the content behaves when the scene is over scrolled.
+ *
+ * @see verticalOverscrollEffect
+ */
+ val horizontalOverscrollEffect: ContentOverscrollEffect
+
+ /**
* Animate some value at the content level.
*
* @param value the value of this shared value in the current content.
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index bdc1461..d7bac14 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -49,8 +49,10 @@
import com.android.compose.animation.scene.content.Overlay
import com.android.compose.animation.scene.content.Scene
import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.effect.GestureEffect
import com.android.compose.ui.util.lerp
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
/** The type for the content of movable elements. */
internal typealias MovableElementContent = @Composable (@Composable () -> Unit) -> Unit
@@ -134,6 +136,18 @@
_movableContents = it
}
+ internal var horizontalOverscrollableContent =
+ OverscrollableContent(
+ animationScope = animationScope,
+ overscrollEffect = { content(it).scope.horizontalOverscrollGestureEffect },
+ )
+
+ internal var verticalOverscrollableContent =
+ OverscrollableContent(
+ animationScope = animationScope,
+ overscrollEffect = { content(it).scope.verticalOverscrollGestureEffect },
+ )
+
/**
* The different values of a shared value keyed by a a [ValueKey] and the different elements and
* contents it is associated to.
@@ -561,3 +575,23 @@
return layout(width, height) { placeable.place(0, 0) }
}
}
+
+internal class OverscrollableContent(
+ private val animationScope: CoroutineScope,
+ private val overscrollEffect: (ContentKey) -> GestureEffect,
+) {
+ private var currentContent: ContentKey? = null
+ var currentOverscrollEffect: GestureEffect? = null
+
+ fun applyOverscrollEffectOn(contentKey: ContentKey): GestureEffect {
+ if (currentContent == contentKey) return currentOverscrollEffect!!
+
+ currentOverscrollEffect?.apply { animationScope.launch { ensureApplyToFlingIsCalled() } }
+
+ // We are wrapping the overscroll effect.
+ val overscrollEffect = overscrollEffect(contentKey)
+ currentContent = contentKey
+ currentOverscrollEffect = overscrollEffect
+ return overscrollEffect
+ }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
index ae235e5..35cdf81 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
@@ -313,6 +313,17 @@
fun isAnimatingOffset(): Boolean = offsetAnimation != null
+ /** Get the [ContentKey] ([fromContent] or [toContent]) associated to the current [direction] */
+ fun contentByDirection(direction: Float): T {
+ require(direction != 0f) { "Cannot find a content in this direction: $direction" }
+ val isDirectionToContent = (isUpOrLeft && direction < 0) || (!isUpOrLeft && direction > 0)
+ return if (isDirectionToContent) {
+ toContent
+ } else {
+ fromContent
+ }
+ }
+
/**
* Animate the offset to a [targetContent], using the [initialVelocity] and an optional [spec]
*
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
index 8c4cd8c..152f05e 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
@@ -17,6 +17,7 @@
package com.android.compose.animation.scene.content
import android.annotation.SuppressLint
+import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -51,6 +52,9 @@
import com.android.compose.animation.scene.UserActionResult
import com.android.compose.animation.scene.ValueKey
import com.android.compose.animation.scene.animateSharedValueAsState
+import com.android.compose.animation.scene.effect.GestureEffect
+import com.android.compose.animation.scene.effect.OffsetOverscrollEffect
+import com.android.compose.animation.scene.effect.VisualEffect
import com.android.compose.animation.scene.element
import com.android.compose.animation.scene.modifiers.noResizeDuringTransitions
import com.android.compose.animation.scene.nestedScrollToScene
@@ -109,6 +113,26 @@
override val layoutState: SceneTransitionLayoutState = layoutImpl.state
+ private val _verticalOverscrollEffect =
+ OffsetOverscrollEffect(
+ orientation = Orientation.Vertical,
+ animationScope = layoutImpl.animationScope,
+ )
+
+ private val _horizontalOverscrollEffect =
+ OffsetOverscrollEffect(
+ orientation = Orientation.Horizontal,
+ animationScope = layoutImpl.animationScope,
+ )
+
+ val verticalOverscrollGestureEffect = GestureEffect(_verticalOverscrollEffect)
+
+ val horizontalOverscrollGestureEffect = GestureEffect(_horizontalOverscrollEffect)
+
+ override val verticalOverscrollEffect = VisualEffect(_verticalOverscrollEffect)
+
+ override val horizontalOverscrollEffect = VisualEffect(_horizontalOverscrollEffect)
+
override fun Modifier.element(key: ElementKey): Modifier {
return element(layoutImpl, content, key)
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/ContentOverscrollEffect.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/ContentOverscrollEffect.kt
new file mode 100644
index 0000000..2233deb
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/ContentOverscrollEffect.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2024 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 com.android.compose.animation.scene.effect
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.foundation.OverscrollEffect
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.geometry.Offset
+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.CoroutineScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * An [OverscrollEffect] that uses an [Animatable] to track and animate overscroll values along a
+ * specific [Orientation].
+ */
+interface ContentOverscrollEffect : OverscrollEffect {
+ /** The current overscroll value. */
+ val overscrollDistance: Float
+}
+
+open class BaseContentOverscrollEffect(
+ orientation: Orientation,
+ private val animationScope: CoroutineScope,
+ private val animationSpec: AnimationSpec<Float>,
+) : ContentOverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) {
+
+ /** The [Animatable] that holds the current overscroll value. */
+ private val animatable = Animatable(initialValue = 0f, visibilityThreshold = 0.5f)
+
+ override val overscrollDistance: Float
+ get() = animatable.value
+
+ override val isInProgress: Boolean
+ get() = overscrollDistance != 0f
+
+ override fun applyToScroll(
+ delta: Offset,
+ source: NestedScrollSource,
+ performScroll: (Offset) -> Offset,
+ ): Offset {
+ val deltaForAxis = delta.toFloat()
+
+ // If we're currently overscrolled, and the user scrolls in the opposite direction, we need
+ // to "relax" the overscroll by consuming some of the scroll delta to bring it back towards
+ // zero.
+ val currentOffset = animatable.value
+ val sameDirection = deltaForAxis.sign == currentOffset.sign
+ val consumedByPreScroll =
+ if (abs(currentOffset) > 0.5 && !sameDirection) {
+ // The user has scrolled in the opposite direction.
+ val prevOverscrollValue = currentOffset
+ val newOverscrollValue = currentOffset + deltaForAxis
+ if (sign(prevOverscrollValue) != sign(newOverscrollValue)) {
+ // Enough to completely cancel the overscroll. We snap the overscroll value
+ // back to zero and consume the corresponding amount of the scroll delta.
+ animationScope.launch { animatable.snapTo(0f) }
+ -prevOverscrollValue
+ } else {
+ // Not enough to cancel the overscroll. We update the overscroll value
+ // accordingly and consume the entire scroll delta.
+ animationScope.launch { animatable.snapTo(newOverscrollValue) }
+ deltaForAxis
+ }
+ } else {
+ 0f
+ }
+ .toOffset()
+
+ // After handling any overscroll relaxation, we pass the remaining scroll delta to the
+ // standard scrolling logic.
+ val leftForScroll = delta - consumedByPreScroll
+ val consumedByScroll = performScroll(leftForScroll)
+ val overscrollDelta = leftForScroll - consumedByScroll
+
+ // If the user is dragging (not flinging), and there's any remaining scroll delta after the
+ // standard scrolling logic has been applied, we add it to the overscroll.
+ if (abs(overscrollDelta.toFloat()) > 0.5 && source == NestedScrollSource.UserInput) {
+ animationScope.launch { animatable.snapTo(currentOffset + overscrollDelta.toFloat()) }
+ }
+
+ return delta
+ }
+
+ override suspend fun applyToFling(
+ velocity: Velocity,
+ performFling: suspend (Velocity) -> Velocity,
+ ) {
+ // We launch a coroutine to ensure the fling animation starts after any pending [snapTo]
+ // animations have finished.
+ // This guarantees a smooth, sequential execution of animations on the overscroll value.
+ coroutineScope {
+ launch {
+ val consumed = performFling(velocity)
+ val remaining = velocity - consumed
+ animatable.animateTo(0f, animationSpec, remaining.toFloat())
+ }
+ }
+ }
+}
+
+/** An overscroll effect that ensures only a single fling animation is triggered. */
+internal class GestureEffect(private val delegate: ContentOverscrollEffect) :
+ ContentOverscrollEffect by delegate {
+ private var shouldFling = false
+
+ override fun applyToScroll(
+ delta: Offset,
+ source: NestedScrollSource,
+ performScroll: (Offset) -> Offset,
+ ): Offset {
+ shouldFling = true
+ return delegate.applyToScroll(delta, source, performScroll)
+ }
+
+ override suspend fun applyToFling(
+ velocity: Velocity,
+ performFling: suspend (Velocity) -> Velocity,
+ ) {
+ if (!shouldFling) {
+ performFling(velocity)
+ return
+ }
+ shouldFling = false
+ delegate.applyToFling(velocity, performFling)
+ }
+
+ suspend fun ensureApplyToFlingIsCalled() {
+ applyToFling(Velocity.Zero) { Velocity.Zero }
+ }
+}
+
+/**
+ * An overscroll effect that only applies visual effects and does not interfere with the actual
+ * scrolling or flinging behavior.
+ */
+internal class VisualEffect(private val delegate: ContentOverscrollEffect) :
+ ContentOverscrollEffect by delegate {
+ override fun applyToScroll(
+ delta: Offset,
+ source: NestedScrollSource,
+ performScroll: (Offset) -> Offset,
+ ): Offset {
+ return performScroll(delta)
+ }
+
+ override suspend fun applyToFling(
+ velocity: Velocity,
+ performFling: suspend (Velocity) -> Velocity,
+ ) {
+ performFling(velocity)
+ }
+}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffect.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffect.kt
new file mode 100644
index 0000000..f459c46
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffect.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2024 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 com.android.compose.animation.scene.effect
+
+import androidx.annotation.VisibleForTesting
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.OverscrollEffect
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import com.android.compose.animation.scene.ProgressConverter
+import kotlin.math.roundToInt
+import kotlinx.coroutines.CoroutineScope
+
+/** An [OverscrollEffect] that offsets the content by the overscroll value. */
+class OffsetOverscrollEffect(
+ orientation: Orientation,
+ animationScope: CoroutineScope,
+ animationSpec: AnimationSpec<Float> = DefaultAnimationSpec,
+) : BaseContentOverscrollEffect(orientation, animationScope, animationSpec) {
+ private var _node: DelegatableNode = newNode()
+ override val node: DelegatableNode
+ get() = _node
+
+ fun newNode(): DelegatableNode {
+ return object : Modifier.Node(), LayoutModifierNode {
+ override fun onDetach() {
+ super.onDetach()
+ // TODO(b/379086317) Remove this workaround: avoid to reuse the same node.
+ _node = newNode()
+ }
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ ): MeasureResult {
+ val placeable = measurable.measure(constraints)
+ return layout(placeable.width, placeable.height) {
+ val offsetPx = computeOffset(density = this@measure, overscrollDistance)
+ placeable.placeRelativeWithLayer(position = offsetPx.toIntOffset())
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val MaxDistance = 400.dp
+
+ internal val DefaultAnimationSpec =
+ spring(
+ stiffness = Spring.StiffnessLow,
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ visibilityThreshold = 0.5f,
+ )
+
+ @VisibleForTesting
+ internal fun computeOffset(density: Density, overscrollDistance: Float): Int {
+ val maxDistancePx = with(density) { MaxDistance.toPx() }
+ val progress = ProgressConverter.Default.convert(overscrollDistance / maxDistancePx)
+ return (progress * maxDistancePx).roundToInt()
+ }
+ }
+}
+
+@Composable
+fun rememberOffsetOverscrollEffect(
+ orientation: Orientation,
+ animationSpec: AnimationSpec<Float> = OffsetOverscrollEffect.DefaultAnimationSpec,
+): OffsetOverscrollEffect {
+ val animationScope = rememberCoroutineScope()
+ return remember(orientation, animationScope, animationSpec) {
+ OffsetOverscrollEffect(orientation, animationScope, animationSpec)
+ }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
index a301856..f1da01f 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt
@@ -30,6 +30,7 @@
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.overscroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.Text
@@ -47,6 +48,7 @@
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.approachLayout
import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
@@ -60,6 +62,7 @@
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
@@ -72,6 +75,7 @@
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.effect.OffsetOverscrollEffect
import com.android.compose.animation.scene.subjects.assertThat
import com.android.compose.test.assertSizeIsEqualTo
import com.android.compose.test.setContentAndCreateMainScope
@@ -712,7 +716,7 @@
}
@Test
- fun elementTransitionDuringOverscroll() {
+ fun elementTransitionDuringOverscrollWithOverscrollDSL() {
val layoutWidth = 200.dp
val layoutHeight = 400.dp
val overscrollTranslateY = 10.dp
@@ -765,6 +769,241 @@
assertThat(animatedFloat).isEqualTo(100f)
}
+ private fun expectedOffset(currentOffset: Dp, density: Density): Dp {
+ return with(density) {
+ OffsetOverscrollEffect.computeOffset(this, currentOffset.toPx()).toDp()
+ }
+ }
+
+ @Test
+ fun elementTransitionDuringOverscroll() {
+ val layoutWidth = 200.dp
+ val layoutHeight = 400.dp
+ lateinit var density: Density
+
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ val state =
+ rule.runOnUiThread {
+ MutableSceneTransitionLayoutState(
+ initialScene = SceneA,
+ transitions = transitions { overscrollDisabled(SceneB, Orientation.Vertical) },
+ )
+ }
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) {
+ scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) {
+ Spacer(Modifier.fillMaxSize())
+ }
+ scene(SceneB) {
+ Spacer(
+ Modifier.overscroll(verticalOverscrollEffect)
+ .fillMaxSize()
+ .element(TestElements.Foo)
+ )
+ }
+ }
+ }
+ assertThat(state.transitionState).isIdle()
+
+ // Swipe by half of verticalSwipeDistance.
+ rule.onRoot().performTouchInput {
+ val middleTop = Offset((layoutWidth / 2).toPx(), 0f)
+ down(middleTop)
+ // Scroll 50%.
+ val firstScrollHeight = layoutHeight.toPx() * 0.5f
+ moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000)
+ }
+
+ rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ val transition = assertThat(state.transitionState).isSceneTransition()
+ assertThat(transition).isNotNull()
+ assertThat(transition).hasProgress(0.5f)
+
+ rule.onRoot().performTouchInput {
+ // Scroll another 100%.
+ moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000)
+ }
+
+ // Scroll 150% (Scene B overscroll by 50%).
+ assertThat(transition).hasProgress(1f)
+
+ rule
+ .onNodeWithTag(TestElements.Foo.testTag)
+ .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density))
+ }
+
+ @Test
+ fun elementTransitionOverscrollMultipleScenes() {
+ val layoutWidth = 200.dp
+ val layoutHeight = 400.dp
+ lateinit var density: Density
+
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ val state =
+ rule.runOnUiThread {
+ MutableSceneTransitionLayoutState(
+ initialScene = SceneA,
+ transitions =
+ transitions {
+ overscrollDisabled(SceneA, Orientation.Vertical)
+ overscrollDisabled(SceneB, Orientation.Vertical)
+ },
+ )
+ }
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) {
+ scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) {
+ Spacer(
+ Modifier.overscroll(verticalOverscrollEffect)
+ .fillMaxSize()
+ .element(TestElements.Foo)
+ )
+ }
+ scene(SceneB) {
+ Spacer(
+ Modifier.overscroll(verticalOverscrollEffect)
+ .fillMaxSize()
+ .element(TestElements.Bar)
+ )
+ }
+ }
+ }
+ assertThat(state.transitionState).isIdle()
+
+ // Swipe by half of verticalSwipeDistance.
+ rule.onRoot().performTouchInput {
+ val middleTop = Offset((layoutWidth / 2).toPx(), 0f)
+ down(middleTop)
+ val firstScrollHeight = layoutHeight.toPx() * 0.5f // Scroll 50%
+ moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000)
+ }
+
+ rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ val transition = assertThat(state.transitionState).isSceneTransition()
+ assertThat(transition).isNotNull()
+ assertThat(transition).hasProgress(0.5f)
+
+ rule.onRoot().performTouchInput {
+ // Scroll another 100%.
+ moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000)
+ }
+
+ // Scroll 150% (Scene B overscroll by 50%).
+ assertThat(transition).hasProgress(1f)
+
+ rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ rule
+ .onNodeWithTag(TestElements.Bar.testTag)
+ .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density))
+
+ rule.onRoot().performTouchInput {
+ // Scroll another -30%.
+ moveBy(Offset(0f, layoutHeight.toPx() * -0.3f), delayMillis = 1_000)
+ }
+
+ // Scroll 120% (Scene B overscroll by 20%).
+ assertThat(transition).hasProgress(1f)
+
+ rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ rule
+ .onNodeWithTag(TestElements.Bar.testTag)
+ .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.2f, density))
+ rule.onRoot().performTouchInput {
+ // Scroll another -70%
+ moveBy(Offset(0f, layoutHeight.toPx() * -0.7f), delayMillis = 1_000)
+ }
+
+ // Scroll 50% (No overscroll).
+ assertThat(transition).hasProgress(0.5f)
+
+ rule.onNodeWithTag(TestElements.Foo.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onRoot().performTouchInput {
+ // Scroll another -100%.
+ moveBy(Offset(0f, layoutHeight.toPx() * -1f), delayMillis = 1_000)
+ }
+
+ // Scroll -50% (Scene A overscroll by -50%).
+ assertThat(transition).hasProgress(0f)
+ rule
+ .onNodeWithTag(TestElements.Foo.testTag)
+ .assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * -0.5f, density))
+ rule.onNodeWithTag(TestElements.Bar.testTag).assertTopPositionInRootIsEqualTo(0.dp)
+ }
+
+ @Test
+ fun elementTransitionOverscroll() {
+ val layoutWidth = 200.dp
+ val layoutHeight = 400.dp
+ lateinit var density: Density
+
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ val state =
+ rule.runOnUiThread {
+ MutableSceneTransitionLayoutState(
+ initialScene = SceneA,
+ transitions =
+ transitions {
+ defaultOverscrollProgressConverter = ProgressConverter.linear()
+ overscrollDisabled(SceneB, Orientation.Vertical)
+ },
+ )
+ }
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ SceneTransitionLayout(state, Modifier.size(layoutWidth, layoutHeight)) {
+ scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) {
+ Spacer(Modifier.fillMaxSize())
+ }
+ scene(SceneB) {
+ Spacer(
+ Modifier.overscroll(verticalOverscrollEffect)
+ .element(TestElements.Foo)
+ .fillMaxSize()
+ )
+ }
+ }
+ }
+ assertThat(state.transitionState).isIdle()
+
+ // Swipe by half of verticalSwipeDistance.
+ rule.onRoot().performTouchInput {
+ val middleTop = Offset((layoutWidth / 2).toPx(), 0f)
+ down(middleTop)
+ val firstScrollHeight = layoutHeight.toPx() * 0.5f // Scroll 50%
+ moveBy(Offset(0f, touchSlop + firstScrollHeight), delayMillis = 1_000)
+ }
+
+ val fooElement = rule.onNodeWithTag(TestElements.Foo.testTag)
+ fooElement.assertTopPositionInRootIsEqualTo(0.dp)
+ val transition = assertThat(state.transitionState).isSceneTransition()
+ assertThat(transition).isNotNull()
+ assertThat(transition).hasProgress(0.5f)
+
+ rule.onRoot().performTouchInput {
+ // Scroll another 100%.
+ moveBy(Offset(0f, layoutHeight.toPx()), delayMillis = 1_000)
+ }
+
+ // Scroll 150% (Scene B overscroll by 50%).
+ assertThat(transition).hasProgress(1f)
+
+ fooElement.assertTopPositionInRootIsEqualTo(expectedOffset(layoutHeight * 0.5f, density))
+ }
+
@Test
fun elementTransitionDuringNestedScrollOverscroll() {
// The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffectTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffectTest.kt
new file mode 100644
index 0000000..d267cc5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/effect/OffsetOverscrollEffectTest.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2024 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 com.android.compose.animation.scene.effect
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.rememberScrollableState
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.overscroll
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class OffsetOverscrollEffectTest {
+ @get:Rule val rule = createComposeRule()
+
+ private fun expectedOffset(currentOffset: Dp, density: Density): Dp {
+ return with(density) {
+ OffsetOverscrollEffect.computeOffset(this, currentOffset.toPx()).toDp()
+ }
+ }
+
+ @Test
+ fun applyVerticalOffset_duringVerticalOverscroll() {
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ lateinit var density: Density
+ val layoutSize = 200.dp
+
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ val overscrollEffect = rememberOffsetOverscrollEffect(Orientation.Vertical)
+
+ Box(
+ Modifier.overscroll(overscrollEffect)
+ // A scrollable that does not consume the scroll gesture.
+ .scrollable(
+ state = rememberScrollableState { 0f },
+ orientation = Orientation.Vertical,
+ overscrollEffect = overscrollEffect,
+ )
+ .size(layoutSize)
+ .testTag("box")
+ )
+ }
+
+ val onBox = rule.onNodeWithTag("box")
+
+ onBox.assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onRoot().performTouchInput {
+ down(center)
+ moveBy(Offset(0f, touchSlop + layoutSize.toPx()), delayMillis = 1_000)
+ }
+
+ onBox.assertTopPositionInRootIsEqualTo(expectedOffset(layoutSize, density))
+ }
+
+ @Test
+ fun applyNoOffset_duringHorizontalOverscroll() {
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ val layoutSize = 200.dp
+
+ rule.setContent {
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ val overscrollEffect = rememberOffsetOverscrollEffect(Orientation.Vertical)
+
+ Box(
+ Modifier.overscroll(overscrollEffect)
+ // A scrollable that does not consume the scroll gesture.
+ .scrollable(
+ state = rememberScrollableState { 0f },
+ orientation = Orientation.Horizontal,
+ overscrollEffect = overscrollEffect,
+ )
+ .size(layoutSize)
+ .testTag("box")
+ )
+ }
+
+ val onBox = rule.onNodeWithTag("box")
+
+ onBox.assertTopPositionInRootIsEqualTo(0.dp)
+
+ rule.onRoot().performTouchInput {
+ down(center)
+ moveBy(Offset(touchSlop + layoutSize.toPx(), 0f), delayMillis = 1_000)
+ }
+
+ onBox.assertTopPositionInRootIsEqualTo(0.dp)
+ }
+
+ @Test
+ fun backToZero_afterOverscroll() {
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ lateinit var density: Density
+ val layoutSize = 200.dp
+
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ val overscrollEffect = rememberOffsetOverscrollEffect(Orientation.Vertical)
+
+ Box(
+ Modifier.overscroll(overscrollEffect)
+ // A scrollable that does not consume the scroll gesture.
+ .scrollable(
+ state = rememberScrollableState { 0f },
+ orientation = Orientation.Vertical,
+ overscrollEffect = overscrollEffect,
+ )
+ .size(layoutSize)
+ .testTag("box")
+ )
+ }
+
+ val onBox = rule.onNodeWithTag("box")
+
+ rule.onRoot().performTouchInput {
+ down(center)
+ moveBy(Offset(0f, touchSlop + layoutSize.toPx()), delayMillis = 1_000)
+ }
+
+ onBox.assertTopPositionInRootIsEqualTo(expectedOffset(layoutSize, density))
+
+ rule.onRoot().performTouchInput { up() }
+
+ onBox.assertTopPositionInRootIsEqualTo(0.dp)
+ }
+
+ @Test
+ fun offsetOverscroll_followTheTouchPointer() {
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+ lateinit var density: Density
+ val layoutSize = 200.dp
+
+ rule.setContent {
+ density = LocalDensity.current
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ val overscrollEffect = rememberOffsetOverscrollEffect(Orientation.Vertical)
+
+ Box(
+ Modifier.overscroll(overscrollEffect)
+ // A scrollable that does not consume the scroll gesture.
+ .scrollable(
+ state = rememberScrollableState { 0f },
+ orientation = Orientation.Vertical,
+ overscrollEffect = overscrollEffect,
+ )
+ .size(layoutSize)
+ .testTag("box")
+ )
+ }
+
+ val onBox = rule.onNodeWithTag("box")
+
+ rule.onRoot().performTouchInput {
+ down(center)
+ // A full screen scroll.
+ moveBy(Offset(0f, touchSlop + layoutSize.toPx()), delayMillis = 1_000)
+ }
+ onBox.assertTopPositionInRootIsEqualTo(expectedOffset(layoutSize, density))
+
+ rule.onRoot().performTouchInput {
+ // Reduced by half.
+ moveBy(Offset(0f, -layoutSize.toPx() / 2), delayMillis = 1_000)
+ }
+ onBox.assertTopPositionInRootIsEqualTo(expectedOffset(layoutSize / 2, density))
+ }
+}