• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.gesture.effect
18 
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.SpringSpec
22 import androidx.compose.animation.core.spring
23 import androidx.compose.foundation.OverscrollEffect
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.ui.geometry.Offset
26 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
27 import androidx.compose.ui.unit.Velocity
28 import com.android.compose.ui.util.HorizontalSpaceVectorConverter
29 import com.android.compose.ui.util.SpaceVectorConverter
30 import com.android.compose.ui.util.VerticalSpaceVectorConverter
31 import kotlin.math.abs
32 import kotlin.math.sign
33 import kotlinx.coroutines.CoroutineScope
34 import kotlinx.coroutines.coroutineScope
35 import kotlinx.coroutines.launch
36 
37 /**
38  * An [OverscrollEffect] that uses an [Animatable] to track and animate overscroll values along a
39  * specific [Orientation].
40  */
41 interface ContentOverscrollEffect : OverscrollEffect {
42     /** The current overscroll value. */
43     val overscrollDistance: Float
44 }
45 
46 open class BaseContentOverscrollEffect(
47     private val animationScope: CoroutineScope,
48     private val animationSpec: AnimationSpec<Float>,
49 ) : ContentOverscrollEffect {
50     /** The [Animatable] that holds the current overscroll value. */
51     private val animatable = Animatable(initialValue = 0f)
52     private var lastConverter: SpaceVectorConverter? = null
53 
54     override val overscrollDistance: Float
55         get() = animatable.value
56 
57     override val isInProgress: Boolean
58         /**
59          * We need both checks, because [overscrollDistance] can be
60          * - zero while it is already being animated, if the animation starts from 0
61          * - greater than zero without an animation, if the content is still being dragged
62          */
63         get() = overscrollDistance != 0f || animatable.isRunning
64 
applyToScrollnull65     override fun applyToScroll(
66         delta: Offset,
67         source: NestedScrollSource,
68         performScroll: (Offset) -> Offset,
69     ): Offset {
70         val converter = converterOrNull(delta.x, delta.y) ?: return performScroll(delta)
71         return converter.applyToScroll(delta, source, performScroll)
72     }
73 
applyToScrollnull74     private fun SpaceVectorConverter.applyToScroll(
75         delta: Offset,
76         source: NestedScrollSource,
77         performScroll: (Offset) -> Offset,
78     ): Offset {
79         val deltaForAxis = delta.toFloat()
80 
81         // If we're currently overscrolled, and the user scrolls in the opposite direction, we need
82         // to "relax" the overscroll by consuming some of the scroll delta to bring it back towards
83         // zero.
84         val currentOffset = animatable.value
85         val sameDirection = deltaForAxis.sign == currentOffset.sign
86         val consumedByPreScroll =
87             if (abs(currentOffset) > 0.5 && !sameDirection) {
88                     // The user has scrolled in the opposite direction.
89                     val prevOverscrollValue = currentOffset
90                     val newOverscrollValue = currentOffset + deltaForAxis
91                     if (sign(prevOverscrollValue) != sign(newOverscrollValue)) {
92                         // Enough to completely cancel the overscroll. We snap the overscroll value
93                         // back to zero and consume the corresponding amount of the scroll delta.
94                         animationScope.launch { animatable.snapTo(0f) }
95                         -prevOverscrollValue
96                     } else {
97                         // Not enough to cancel the overscroll. We update the overscroll value
98                         // accordingly and consume the entire scroll delta.
99                         animationScope.launch { animatable.snapTo(newOverscrollValue) }
100                         deltaForAxis
101                     }
102                 } else {
103                     0f
104                 }
105                 .toOffset()
106 
107         // After handling any overscroll relaxation, we pass the remaining scroll delta to the
108         // standard scrolling logic.
109         val leftForScroll = delta - consumedByPreScroll
110         val consumedByScroll = performScroll(leftForScroll)
111         val overscrollDelta = leftForScroll - consumedByScroll
112 
113         // If the user is dragging (not flinging), and there's any remaining scroll delta after the
114         // standard scrolling logic has been applied, we add it to the overscroll.
115         if (abs(overscrollDelta.toFloat()) > 0.5 && source == NestedScrollSource.UserInput) {
116             animationScope.launch { animatable.snapTo(currentOffset + overscrollDelta.toFloat()) }
117         }
118 
119         return delta
120     }
121 
applyToFlingnull122     override suspend fun applyToFling(
123         velocity: Velocity,
124         performFling: suspend (Velocity) -> Velocity,
125     ) {
126         val converter = converterOrNull(velocity.x, velocity.y) ?: return
127         converter.applyToFling(velocity, performFling)
128     }
129 
applyToFlingnull130     private suspend fun SpaceVectorConverter.applyToFling(
131         velocity: Velocity,
132         performFling: suspend (Velocity) -> Velocity,
133     ) {
134         // We launch a coroutine to ensure the fling animation starts after any pending [snapTo]
135         // animations have finished.
136         // This guarantees a smooth, sequential execution of animations on the overscroll value.
137         coroutineScope {
138             launch {
139                 val consumed = performFling(velocity)
140                 val remaining = velocity - consumed
141                 animatable.animateTo(
142                     0f,
143                     animationSpec.withVisibilityThreshold(1f),
144                     remaining.toFloat(),
145                 )
146             }
147         }
148     }
149 
withVisibilityThresholdnull150     private fun <T> AnimationSpec<T>.withVisibilityThreshold(
151         visibilityThreshold: T
152     ): AnimationSpec<T> {
153         return when (this) {
154             is SpringSpec ->
155                 spring(
156                     stiffness = stiffness,
157                     dampingRatio = dampingRatio,
158                     visibilityThreshold = visibilityThreshold,
159                 )
160             else -> this
161         }
162     }
163 
requireConverternull164     protected fun requireConverter(): SpaceVectorConverter {
165         return checkNotNull(lastConverter) {
166             "lastConverter is null, make sure to call requireConverter() only when " +
167                 "overscrollDistance != 0f"
168         }
169     }
170 
converterOrNullnull171     private fun converterOrNull(x: Float, y: Float): SpaceVectorConverter? {
172         val converter: SpaceVectorConverter =
173             when {
174                 x != 0f && y != 0f ->
175                     error(
176                         "BaseContentOverscrollEffect only supports single orientation scrolls " +
177                             "and velocities"
178                     )
179                 x == 0f && y == 0f -> lastConverter ?: return null
180                 x != 0f -> HorizontalSpaceVectorConverter
181                 else -> VerticalSpaceVectorConverter
182             }
183 
184         if (lastConverter != null) {
185             check(lastConverter == converter) {
186                 "BaseContentOverscrollEffect should always be used in the same orientation"
187             }
188         } else {
189             lastConverter = converter
190         }
191 
192         return converter
193     }
194 }
195