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