1 /*
2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.compose.animation.scene.effect
18
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.foundation.OverscrollEffect
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.ui.geometry.Offset
24 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
25 import androidx.compose.ui.unit.Velocity
26 import com.android.compose.ui.util.SpaceVectorConverter
27 import kotlin.math.abs
28 import kotlin.math.sign
29 import kotlinx.coroutines.CoroutineScope
30 import kotlinx.coroutines.coroutineScope
31 import kotlinx.coroutines.launch
32
33 /**
34 * An [OverscrollEffect] that uses an [Animatable] to track and animate overscroll values along a
35 * specific [Orientation].
36 */
37 interface ContentOverscrollEffect : OverscrollEffect {
38 /** The current overscroll value. */
39 val overscrollDistance: Float
40 }
41
42 open class BaseContentOverscrollEffect(
43 orientation: Orientation,
44 private val animationScope: CoroutineScope,
45 private val animationSpec: AnimationSpec<Float>,
<lambda>null46 ) : ContentOverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) {
47
48 /** The [Animatable] that holds the current overscroll value. */
49 private val animatable = Animatable(initialValue = 0f, visibilityThreshold = 0.5f)
50
51 override val overscrollDistance: Float
52 get() = animatable.value
53
54 override val isInProgress: Boolean
55 get() = overscrollDistance != 0f
56
57 override fun applyToScroll(
58 delta: Offset,
59 source: NestedScrollSource,
60 performScroll: (Offset) -> Offset,
61 ): Offset {
62 val deltaForAxis = delta.toFloat()
63
64 // If we're currently overscrolled, and the user scrolls in the opposite direction, we need
65 // to "relax" the overscroll by consuming some of the scroll delta to bring it back towards
66 // zero.
67 val currentOffset = animatable.value
68 val sameDirection = deltaForAxis.sign == currentOffset.sign
69 val consumedByPreScroll =
70 if (abs(currentOffset) > 0.5 && !sameDirection) {
71 // The user has scrolled in the opposite direction.
72 val prevOverscrollValue = currentOffset
73 val newOverscrollValue = currentOffset + deltaForAxis
74 if (sign(prevOverscrollValue) != sign(newOverscrollValue)) {
75 // Enough to completely cancel the overscroll. We snap the overscroll value
76 // back to zero and consume the corresponding amount of the scroll delta.
77 animationScope.launch { animatable.snapTo(0f) }
78 -prevOverscrollValue
79 } else {
80 // Not enough to cancel the overscroll. We update the overscroll value
81 // accordingly and consume the entire scroll delta.
82 animationScope.launch { animatable.snapTo(newOverscrollValue) }
83 deltaForAxis
84 }
85 } else {
86 0f
87 }
88 .toOffset()
89
90 // After handling any overscroll relaxation, we pass the remaining scroll delta to the
91 // standard scrolling logic.
92 val leftForScroll = delta - consumedByPreScroll
93 val consumedByScroll = performScroll(leftForScroll)
94 val overscrollDelta = leftForScroll - consumedByScroll
95
96 // If the user is dragging (not flinging), and there's any remaining scroll delta after the
97 // standard scrolling logic has been applied, we add it to the overscroll.
98 if (abs(overscrollDelta.toFloat()) > 0.5 && source == NestedScrollSource.UserInput) {
99 animationScope.launch { animatable.snapTo(currentOffset + overscrollDelta.toFloat()) }
100 }
101
102 return delta
103 }
104
105 override suspend fun applyToFling(
106 velocity: Velocity,
107 performFling: suspend (Velocity) -> Velocity,
108 ) {
109 // We launch a coroutine to ensure the fling animation starts after any pending [snapTo]
110 // animations have finished.
111 // This guarantees a smooth, sequential execution of animations on the overscroll value.
112 coroutineScope {
113 launch {
114 val consumed = performFling(velocity)
115 val remaining = velocity - consumed
116 animatable.animateTo(0f, animationSpec, remaining.toFloat())
117 }
118 }
119 }
120 }
121
122 /** An overscroll effect that ensures only a single fling animation is triggered. */
123 internal class GestureEffect(private val delegate: ContentOverscrollEffect) :
<lambda>null124 ContentOverscrollEffect by delegate {
125 private var shouldFling = false
126
127 override fun applyToScroll(
128 delta: Offset,
129 source: NestedScrollSource,
130 performScroll: (Offset) -> Offset,
131 ): Offset {
132 shouldFling = true
133 return delegate.applyToScroll(delta, source, performScroll)
134 }
135
136 override suspend fun applyToFling(
137 velocity: Velocity,
138 performFling: suspend (Velocity) -> Velocity,
139 ) {
140 if (!shouldFling) {
141 performFling(velocity)
142 return
143 }
144 shouldFling = false
145 delegate.applyToFling(velocity, performFling)
146 }
147
148 suspend fun ensureApplyToFlingIsCalled() {
149 applyToFling(Velocity.Zero) { Velocity.Zero }
150 }
151 }
152
153 /**
154 * An overscroll effect that only applies visual effects and does not interfere with the actual
155 * scrolling or flinging behavior.
156 */
157 internal class VisualEffect(private val delegate: ContentOverscrollEffect) :
<lambda>null158 ContentOverscrollEffect by delegate {
159 override fun applyToScroll(
160 delta: Offset,
161 source: NestedScrollSource,
162 performScroll: (Offset) -> Offset,
163 ): Offset {
164 return performScroll(delta)
165 }
166
167 override suspend fun applyToFling(
168 velocity: Velocity,
169 performFling: suspend (Velocity) -> Velocity,
170 ) {
171 performFling(velocity)
172 }
173 }
174