1 /*
2 * Copyright 2021 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 androidx.compose.foundation
18
19 import android.content.Context
20 import android.os.Build
21 import android.util.AttributeSet
22 import android.view.ViewConfiguration
23 import android.widget.EdgeEffect
24 import androidx.annotation.RequiresApi
25 import androidx.compose.ui.unit.Density
26 import androidx.compose.ui.unit.dp
27 import kotlin.math.abs
28 import kotlin.math.exp
29 import kotlin.math.ln
30 import kotlin.math.roundToInt
31
32 internal object EdgeEffectCompat {
33
createnull34 fun create(context: Context): EdgeEffect {
35 return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
36 Api31Impl.create(context, null)
37 } else {
38 GlowEdgeEffectCompat(context)
39 }
40 }
41
EdgeEffectnull42 fun EdgeEffect.onPullDistanceCompat(deltaDistance: Float, displacement: Float): Float {
43 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
44 return Api31Impl.onPullDistance(this, deltaDistance, displacement)
45 }
46 this.onPull(deltaDistance, displacement)
47 return deltaDistance
48 }
49
onAbsorbCompatnull50 fun EdgeEffect.onAbsorbCompat(velocity: Int) {
51 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
52 return this.onAbsorb(velocity)
53 } else if (this.isFinished) {
54 // Only absorb the glow effect if it is not active (finished) - dragging to start a glow
55 // and then releasing shouldn't add to the existing glow, it should just decay.
56 this.onAbsorb(velocity)
57 }
58 }
59
60 /**
61 * When relaxing a stretch with velocity (fling), if the velocity would fully relax the stretch
62 * with some remaining velocity, instead of absorbing we want to propagate the velocity and
63 * relax the overscroll as part of scroll, called within the fling - so for this case we do
64 * nothing and consume no velocity.
65 *
66 * If the velocity is not enough to fully relax the stretch, then we absorb it and consume all
67 * the velocity.
68 *
69 * @param velocity to absorb if needed
70 * @param edgeEffectLength the main axis bounds for this edge effect, needed to transform the
71 * relative distance from [distanceCompat] into the absolute distance
72 * @return how much velocity was consumed
73 */
EdgeEffectnull74 fun EdgeEffect.absorbToRelaxIfNeeded(
75 velocity: Float,
76 edgeEffectLength: Float,
77 density: Density
78 ): Float {
79 val flingDistance = flingDistance(density, velocity)
80 val actualDistance = distanceCompat * edgeEffectLength
81 return if (flingDistance <= actualDistance) {
82 onAbsorbCompat(velocity.roundToInt())
83 // Consume all velocity when absorbing
84 velocity
85 } else {
86 // Consume nothing, we will relax the stretch in applyToScroll
87 0f
88 }
89 }
90
91 /**
92 * Used for calls to [EdgeEffect.onRelease] that happen because of scroll delta in the opposite
93 * direction to the overscroll. See [GlowEdgeEffectCompat].
94 */
onReleaseWithOppositeDeltanull95 fun EdgeEffect.onReleaseWithOppositeDelta(delta: Float) {
96 if (this is GlowEdgeEffectCompat) {
97 releaseWithOppositeDelta(delta)
98 } else {
99 onRelease()
100 }
101 }
102
103 val EdgeEffect.distanceCompat: Float
104 get() {
105 return if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)) {
106 Api31Impl.getDistance(this)
107 } else 0f
108 }
109 }
110
111 /**
112 * Compat class to work around a framework issue (b/242864658) - small negative deltas that release
113 * an overscroll followed by positive deltas cause the glow overscroll effect to instantly
114 * disappear. This can happen when you pull the overscroll, and keep it there - small fluctuations
115 * in the pointer position can cause these small negative deltas, even though on average it is not
116 * really moving. To workaround this we only release the overscroll if the cumulative negative
117 * deltas are larger than a minimum value - this should catch the majority of cases.
118 */
119 private class GlowEdgeEffectCompat(context: Context) : EdgeEffect(context) {
120 // Minimum distance in the opposite scroll direction to trigger a release
<lambda>null121 private val oppositeReleaseDeltaThreshold = with(Density(context)) { 1.dp.toPx() }
122 private var oppositeReleaseDelta = 0f
123
onPullnull124 override fun onPull(deltaDistance: Float, displacement: Float) {
125 oppositeReleaseDelta = 0f
126 super.onPull(deltaDistance, displacement)
127 }
128
onPullnull129 override fun onPull(deltaDistance: Float) {
130 oppositeReleaseDelta = 0f
131 super.onPull(deltaDistance)
132 }
133
onReleasenull134 override fun onRelease() {
135 oppositeReleaseDelta = 0f
136 super.onRelease()
137 }
138
onAbsorbnull139 override fun onAbsorb(velocity: Int) {
140 oppositeReleaseDelta = 0f
141 super.onAbsorb(velocity)
142 }
143
144 /**
145 * Increments the current cumulative delta, and calls [onRelease] if it is greater than
146 * [oppositeReleaseDeltaThreshold].
147 */
releaseWithOppositeDeltanull148 fun releaseWithOppositeDelta(delta: Float) {
149 oppositeReleaseDelta += delta
150 if (abs(oppositeReleaseDelta) > oppositeReleaseDeltaThreshold) {
151 onRelease()
152 }
153 }
154 }
155
156 @RequiresApi(Build.VERSION_CODES.S)
157 private object Api31Impl {
createnull158 fun create(context: Context, attrs: AttributeSet?): EdgeEffect {
159 return try {
160 EdgeEffect(context, attrs)
161 } catch (t: Throwable) {
162 EdgeEffect(context) // Old preview release
163 }
164 }
165
onPullDistancenull166 fun onPullDistance(edgeEffect: EdgeEffect, deltaDistance: Float, displacement: Float): Float {
167 return try {
168 edgeEffect.onPullDistance(deltaDistance, displacement)
169 } catch (t: Throwable) {
170 edgeEffect.onPull(deltaDistance, displacement) // Old preview release
171 0f
172 }
173 }
174
getDistancenull175 fun getDistance(edgeEffect: EdgeEffect): Float {
176 return try {
177 edgeEffect.getDistance()
178 } catch (t: Throwable) {
179 0f // Old preview release
180 }
181 }
182 }
183
184 // These constants are copied from the Android spline decay rate
185 private const val Inflection = 0.35f // Tension lines cross at (Inflection, 1)
186 private val PlatformFlingScrollFriction = ViewConfiguration.getScrollFriction()
187 private const val GravityEarth = 9.80665f
188 private const val InchesPerMeter = 39.37f
189 private val DecelerationRate = ln(0.78) / ln(0.9)
190 private val DecelMinusOne = DecelerationRate - 1.0
191
192 /**
193 * Copied from OverScroller, this returns the distance that a fling with the given velocity will go.
194 *
195 * @return The absolute distance that will be traveled by a fling of the given velocity
196 */
flingDistancenull197 private fun flingDistance(density: Density, velocity: Float): Float {
198 val magicPhysicalCoefficient =
199 (GravityEarth * InchesPerMeter * density.density * 160f * 0.84f).toDouble()
200 val l =
201 ln(Inflection * abs(velocity) / (PlatformFlingScrollFriction * magicPhysicalCoefficient))
202 val distance =
203 PlatformFlingScrollFriction *
204 magicPhysicalCoefficient *
205 exp(DecelerationRate / DecelMinusOne * l)
206 return distance.toFloat()
207 }
208