diff options
9 files changed, 915 insertions, 15 deletions
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 caf5e41576f3..2d589f37f3cb 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.animation.scene.content.state.TransitionState.HasOver 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 class DragControllerImpl( 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 @@ private class DragControllerImpl( * @return the consumed delta */ override fun onDrag(delta: Float): Float { - return onDrag(delta, swipeAnimation) + 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) } - private fun <T : ContentKey> onDrag(delta: Float, swipeAnimation: SwipeAnimation<T>): Float { - if (delta == 0f || !isDrivingTransition || swipeAnimation.isAnimatingOffset()) { - return 0f + 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) } - val distance = swipeAnimation.distance() - val previousOffset = swipeAnimation.dragOffset + 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 @@ private class DragControllerImpl( 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 a14b2b3746f5..bf7e8e823658 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.Dp 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 @@ typealias SceneScope = ContentScope @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 bdc1461f06c9..d7bac147d8f2 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.Content 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 @@ internal class SceneTransitionLayoutImpl( _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 @@ private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) : 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 ae235e5097af..35cdf81e8c14 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 @@ internal class SwipeAnimation<T : ContentKey>( 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 8c4cd8c93b87..152f05eb5cc7 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.UserAction 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 @@ internal class ContentScopeImpl( 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 000000000000..2233debde277 --- /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 000000000000..f459c46d3e6f --- /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 a301856d024f..f1da01fef72c 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.Spacer 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.Modifier 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.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.DpOffset import androidx.compose.ui.unit.DpSize @@ -72,6 +75,7 @@ import com.android.compose.animation.scene.TestScenes.SceneA 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 @@ class ElementTest { } @Test - fun elementTransitionDuringOverscroll() { + fun elementTransitionDuringOverscrollWithOverscrollDSL() { val layoutWidth = 200.dp val layoutHeight = 400.dp val overscrollTranslateY = 10.dp @@ -765,6 +769,241 @@ class ElementTest { 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 000000000000..d267cc5c237f --- /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)) + } +} |