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