@@ -6,6 +6,8 @@ import androidx.compose.animation.core.Spring
66import androidx.compose.animation.core.spring
77import androidx.compose.foundation.gestures.scrollable
88import androidx.compose.foundation.layout.offset
9+ import androidx.compose.runtime.Stable
10+ import androidx.compose.runtime.mutableStateOf
911import androidx.compose.runtime.remember
1012import androidx.compose.ui.Modifier
1113import androidx.compose.ui.composed
@@ -18,111 +20,139 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
1820import androidx.compose.ui.unit.IntOffset
1921import androidx.compose.ui.unit.Velocity
2022import kotlinx.coroutines.async
23+ import kotlinx.coroutines.coroutineScope
2124import kotlinx.coroutines.launch
22- import kotlinx.coroutines.withContext
2325import kotlin.math.abs
2426import kotlin.math.roundToInt
2527import kotlin.math.sign
2628import kotlin.math.sqrt
2729
30+ /* *
31+ * A parabolic rolling easing curve.
32+ *
33+ * When rolling in the same direction, the farther away from 0, the greater the "resistance"; the closer to 0, the smaller the "resistance";
34+ *
35+ * No drag effect is applied when the scrolling direction is opposite to the currently existing overscroll offset
36+ *
37+ * Note: when [p]=50f, its performance should be consistent with iOS
38+ * @param currentOffset Offset value currently out of bounds
39+ * @param newOffset The offset of the new scroll
40+ * @param p Key parameters for parabolic curve calculation
41+ */
42+ @Stable
43+ fun parabolaScrollEasing (currentOffset : Float , newOffset : Float , p : Float = 50f): Float {
44+ val ratio = (p / (sqrt(p * abs(currentOffset + newOffset / 2 ).coerceAtLeast(Float .MIN_VALUE )))).coerceIn(Float .MIN_VALUE , 1f )
45+ return if (sign(currentOffset) == sign(newOffset)) {
46+ currentOffset + newOffset * ratio
47+ } else {
48+ currentOffset + newOffset
49+ }
50+ }
51+
2852/* *
2953 * OverScroll effect for scrollable Composable .
3054 *
3155 * You should call it before Modifiers with similar semantics such as [Modifier.scrollable], so that nested scrolling can work normally
3256 * @Author: cormor
3357 * @Email: cangtiansuo@gmail.com
34- * @param nestedScrollToParent 是否将嵌套滚动事件分发给parent
35- * @param scrollEasing 传入值分别是当前已有的overscrollOffset和新的来自手势的offset,修改它配合[springStiff]以定制滑动阻尼效果。当前默认easing来自iOS,可以不修改!
36- * @param springStiff overscroll的springStiff,为了更好的用户体验,新值不建议高于[Spring.StiffnessMediumLow]
37- * @param springDamp overscroll的springDamp,一般不需要设置
58+ * @param nestedScrollToParent Whether to dispatch nested scroll events to parent
59+ * @param scrollEasing The incoming values are the currently existing overscroll Offset
60+ * and the new offset from the gesture.
61+ * modify it to cooperate with [springStiff] to customize the sliding damping effect.
62+ * The current default easing comes from iOS, you don't need to modify it!
63+ * @param springStiff springStiff for overscroll effect,For better user experience, the new value is not recommended to be higher than[Spring.StiffnessMediumLow]
64+ * @param springDamp springDamp for overscroll effect,generally do not need to set
3865 */
3966fun Modifier.overScrollVertical (
4067 nestedScrollToParent : Boolean = true,
41- scrollEasing : (currentOverscrollOffset: Float , newScrollOffset: Float ) -> Float =
42- { currentOffset, newOffset ->
43- val p = 50f
44- val ratio = (p / (sqrt(p * abs(currentOffset + newOffset / 2).coerceAtLeast(Float .MIN_VALUE )))).coerceIn(Float .MIN_VALUE , 1f)
45- if (sign(currentOffset) == sign(newOffset)) {
46- currentOffset + newOffset * ratio
47- } else {
48- currentOffset + newOffset
49- }
50- },
68+ scrollEasing : (currentOffset: Float , newOffset: Float ) -> Float = @Stable { currentOffset, newOffset -> parabolaScrollEasing(currentOffset, newOffset) },
5169 springStiff : Float = 300f,
5270 springDamp : Float = Spring .DampingRatioNoBouncy ,
5371): Modifier = composed {
5472 val dispatcher = remember { NestedScrollDispatcher () }
55- val overscrollOffset = remember { Animatable (0f ) }
73+ val overscrollY = remember { mutableStateOf (0f ) }
5674
5775 val nestedConnection = remember(nestedScrollToParent, springStiff, springDamp) {
5876 object : NestedScrollConnection {
77+ /* *
78+ * If the offset is less than this value, we consider the animation to end.
79+ */
5980 val visibilityThreshold = 0.5f
6081 lateinit var lastFlingAnimator: Animatable <Float , AnimationVector1D >
6182
83+ var offsetY
84+ get() = overscrollY.value
85+ set(value) {
86+ overscrollY.value = value
87+ }
88+
6289 override fun onPreScroll (available : Offset , source : NestedScrollSource ): Offset {
63- if (this ::lastFlingAnimator.isInitialized) {
90+ if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning ) {
6491 dispatcher.coroutineScope.launch {
6592 lastFlingAnimator.stop()
6693 }
6794 }
68- val parentConsume = when {
69- nestedScrollToParent -> dispatcher.dispatchPreScroll(available, source)
70- else -> Offset . Zero
95+ val realAvailable = when {
96+ nestedScrollToParent -> available - dispatcher.dispatchPreScroll(available, source)
97+ else -> available
7198 }
72- val realAvailable = available - parentConsume
7399
74- val isSameDirection = sign(realAvailable.y) == sign(overscrollOffset.value )
75- if (abs(overscrollOffset.value ) <= visibilityThreshold || isSameDirection) {
76- return parentConsume
100+ val isSameDirection = sign(realAvailable.y) == sign(offsetY )
101+ if (abs(offsetY ) <= visibilityThreshold || isSameDirection) {
102+ return available - realAvailable
77103 }
104+ val offsetAtLast = offsetY + realAvailable.y
78105 // sign changed, coerce to start scrolling and exit
79- if (sign(overscrollOffset.value) != sign(overscrollOffset.value + realAvailable.y)) dispatcher.coroutineScope.launch {
80- overscrollOffset.snapTo(0f )
81- } else dispatcher.coroutineScope.launch {
82- overscrollOffset.snapTo(scrollEasing(overscrollOffset.value, realAvailable.y))
106+ return if (sign(offsetY) != sign(offsetAtLast)) {
107+ offsetY = 0f
108+ Offset (x = 0f , y = offsetAtLast)
109+ } else {
110+ offsetY = scrollEasing(offsetY, realAvailable.y)
111+ Offset (x = 0f , y = available.y)
83112 }
84- return Offset (x = 0f , y = available.y)
85113 }
86114
87115 override fun onPostScroll (consumed : Offset , available : Offset , source : NestedScrollSource ): Offset {
88116 val realAvailable = when {
89117 nestedScrollToParent -> available - dispatcher.dispatchPostScroll(consumed, available, source)
90118 else -> available
91119 }
92- dispatcher.coroutineScope.launch {
93- when (source) {
94- NestedScrollSource .Fling -> overscrollOffset.snapTo(overscrollOffset.value + realAvailable.y)
95- else -> overscrollOffset.snapTo(scrollEasing(overscrollOffset.value, realAvailable.y))
96- }
120+ when (source) {
121+ NestedScrollSource .Fling -> offsetY + = realAvailable.y
122+ else -> offsetY = scrollEasing(offsetY, realAvailable.y)
97123 }
98124 return Offset (x = 0f , y = available.y)
99125 }
100126
101127 override suspend fun onPreFling (available : Velocity ): Velocity {
102- if (this ::lastFlingAnimator.isInitialized) {
103- lastFlingAnimator.snapTo(lastFlingAnimator.value )
128+ if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning ) {
129+ lastFlingAnimator.stop( )
104130 }
105131 val parentConsumed = when {
106132 nestedScrollToParent -> dispatcher.dispatchPreFling(available)
107133 else -> Velocity .Zero
108134 }
109135 val realAvailable = available - parentConsumed
110136 var leftVelocity = realAvailable.y
111- if (abs(overscrollOffset.value ) >= visibilityThreshold && sign(realAvailable.y) != sign(overscrollOffset.value )) {
137+ if (abs(offsetY ) >= visibilityThreshold && sign(realAvailable.y) != sign(offsetY )) {
112138 var lastValue = 0f
113- lastFlingAnimator = Animatable (overscrollOffset.value)
114- dispatcher.coroutineScope.async {
115- lastFlingAnimator.animateTo(0f , spring(springDamp, springStiff, visibilityThreshold), realAvailable.y) {
116- if (abs(value) < visibilityThreshold || sign(value) != sign(lastValue) && lastValue != 0f ) dispatcher.coroutineScope.launch {
117- this @animateTo.stop()
118- overscrollOffset.snapTo(0f )
119- } else dispatcher.coroutineScope.launch {
120- overscrollOffset.snapTo(scrollEasing(overscrollOffset.value, value - overscrollOffset.value))
139+ lastFlingAnimator = Animatable (offsetY)
140+ coroutineScope {
141+ async {
142+ lastFlingAnimator.animateTo(0f , spring(springDamp, springStiff, visibilityThreshold), realAvailable.y) {
143+ if (abs(value) < visibilityThreshold || sign(value) != sign(lastValue) && lastValue != 0f ) {
144+ offsetY = 0f
145+ dispatcher.coroutineScope.launch {
146+ this @animateTo.snapTo(0f )
147+ }
148+ } else {
149+ offsetY = scrollEasing(offsetY, value - offsetY)
150+ }
151+ lastValue = value
152+ leftVelocity = velocity
121153 }
122- lastValue = value
123- leftVelocity = velocity
124- }
125- }.join()
154+ }.join()
155+ }
126156 }
127157 return Velocity (parentConsumed.x, y = available.y - leftVelocity)
128158 }
@@ -132,20 +162,21 @@ fun Modifier.overScrollVertical(
132162 nestedScrollToParent -> available - dispatcher.dispatchPostFling(consumed, available)
133163 else -> available
134164 }
135- lastFlingAnimator = Animatable (overscrollOffset.value)
136- val leftVelocity = withContext(dispatcher.coroutineScope.coroutineContext) {
137- lastFlingAnimator.animateTo(0f , spring(springDamp, springStiff, visibilityThreshold), realAvailable.y) {
138- dispatcher.coroutineScope.launch {
139- overscrollOffset.snapTo(scrollEasing(overscrollOffset.value, value - overscrollOffset.value))
140- }
141- }.endState.velocity
165+ var leftVelocity = 0f
166+ coroutineScope {
167+ async {
168+ lastFlingAnimator = Animatable (offsetY)
169+ leftVelocity = lastFlingAnimator.animateTo(0f , spring(springDamp, springStiff, visibilityThreshold), realAvailable.y) {
170+ offsetY = scrollEasing(offsetY, value - offsetY)
171+ }.endState.velocity
172+ }.join()
142173 }
143- return available.copy( y = available.y - leftVelocity)
174+ return Velocity (x = 0f , y = available.y - leftVelocity)
144175 }
145176 }
146177 }
147178 this
148179 .clipToBounds()
149- .offset { IntOffset (0 , overscrollOffset .value.roundToInt()) }
180+ .offset { IntOffset (0 , overscrollY .value.roundToInt()) }
150181 .nestedScroll(nestedConnection, dispatcher)
151182}
0 commit comments