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.graphics.RenderNode
21 import android.os.Build
22 import android.widget.EdgeEffect
23 import androidx.annotation.ColorInt
24 import androidx.annotation.RequiresApi
25 import androidx.annotation.VisibleForTesting
26 import androidx.compose.foundation.EdgeEffectCompat.absorbToRelaxIfNeeded
27 import androidx.compose.foundation.EdgeEffectCompat.distanceCompat
28 import androidx.compose.foundation.EdgeEffectCompat.onAbsorbCompat
29 import androidx.compose.foundation.EdgeEffectCompat.onPullDistanceCompat
30 import androidx.compose.foundation.EdgeEffectCompat.onReleaseWithOppositeDelta
31 import androidx.compose.foundation.gestures.Orientation
32 import androidx.compose.foundation.gestures.awaitEachGesture
33 import androidx.compose.foundation.gestures.awaitFirstDown
34 import androidx.compose.foundation.layout.PaddingValues
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.CompositionLocalAccessorScope
37 import androidx.compose.runtime.mutableStateOf
38 import androidx.compose.runtime.neverEqualPolicy
39 import androidx.compose.runtime.remember
40 import androidx.compose.ui.geometry.Offset
41 import androidx.compose.ui.geometry.Size
42 import androidx.compose.ui.geometry.center
43 import androidx.compose.ui.geometry.isSpecified
44 import androidx.compose.ui.graphics.Canvas
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.graphics.NativeCanvas
47 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
48 import androidx.compose.ui.graphics.drawscope.DrawScope
49 import androidx.compose.ui.graphics.drawscope.draw
50 import androidx.compose.ui.graphics.drawscope.translate
51 import androidx.compose.ui.graphics.nativeCanvas
52 import androidx.compose.ui.graphics.toArgb
53 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
54 import androidx.compose.ui.input.pointer.PointerId
55 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
56 import androidx.compose.ui.node.DelegatableNode
57 import androidx.compose.ui.node.DelegatingNode
58 import androidx.compose.ui.node.DrawModifierNode
59 import androidx.compose.ui.platform.LocalContext
60 import androidx.compose.ui.platform.LocalDensity
61 import androidx.compose.ui.unit.Density
62 import androidx.compose.ui.unit.IntSize
63 import androidx.compose.ui.unit.Velocity
64 import androidx.compose.ui.util.fastFilter
65 import androidx.compose.ui.util.fastFirstOrNull
66 import kotlin.math.roundToInt
67 
68 /**
69  * Creates and remembers an instance of the platform [OverscrollFactory], with the provided
70  * [glowColor] and [glowDrawPadding] values - these values will be used on platform versions where
71  * glow overscroll is used.
72  *
73  * The OverscrollFactory returned from this function should be provided near the top of your
74  * application to [LocalOverscrollFactory], in order to apply this across all components in your
75  * application.
76  *
77  * @param glowColor color for the glow effect if the platform effect is a glow effect, otherwise
78  *   ignored.
79  * @param glowDrawPadding the amount of padding to apply from the overscroll bounds to the effect
80  *   before drawing it if the platform effect is a glow effect, otherwise ignored.
81  */
82 @Composable
rememberPlatformOverscrollFactorynull83 fun rememberPlatformOverscrollFactory(
84     glowColor: Color = DefaultGlowColor,
85     glowDrawPadding: PaddingValues = DefaultGlowPaddingValues
86 ): OverscrollFactory {
87     val context = LocalContext.current
88     val density = LocalDensity.current
89     return AndroidEdgeEffectOverscrollFactory(context, density, glowColor, glowDrawPadding)
90 }
91 
92 @OptIn(ExperimentalFoundationApi::class)
93 @Suppress("DEPRECATION")
defaultOverscrollFactorynull94 internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? {
95     val context = LocalContext.currentValue
96     val density = LocalDensity.currentValue
97     val config = LocalOverscrollConfiguration.currentValue
98     return if (config == null) {
99         null
100     } else {
101         AndroidEdgeEffectOverscrollFactory(context, density, config.glowColor, config.drawPadding)
102     }
103 }
104 
105 @OptIn(ExperimentalFoundationApi::class)
106 @Suppress("DEPRECATION")
107 @Composable
rememberPlatformOverscrollEffectnull108 internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? {
109     val context = LocalContext.current
110     val density = LocalDensity.current
111     val config = LocalOverscrollConfiguration.current
112     return if (config == null) {
113         null
114     } else {
115         remember(context, density, config) {
116             AndroidEdgeEffectOverscrollEffect(
117                 context,
118                 density,
119                 config.glowColor,
120                 config.drawPadding
121             )
122         }
123     }
124 }
125 
126 private class AndroidEdgeEffectOverscrollFactory(
127     private val context: Context,
128     private val density: Density,
129     private val glowColor: Color = DefaultGlowColor,
130     private val glowDrawPadding: PaddingValues = DefaultGlowPaddingValues
131 ) : OverscrollFactory {
createOverscrollEffectnull132     override fun createOverscrollEffect(): OverscrollEffect {
133         return AndroidEdgeEffectOverscrollEffect(context, density, glowColor, glowDrawPadding)
134     }
135 
equalsnull136     override fun equals(other: Any?): Boolean {
137         if (this === other) return true
138         if (javaClass != other?.javaClass) return false
139 
140         other as AndroidEdgeEffectOverscrollFactory
141 
142         if (context != other.context) return false
143         if (density != other.density) return false
144         if (glowColor != other.glowColor) return false
145         if (glowDrawPadding != other.glowDrawPadding) return false
146 
147         return true
148     }
149 
hashCodenull150     override fun hashCode(): Int {
151         var result = context.hashCode()
152         result = 31 * result + density.hashCode()
153         result = 31 * result + glowColor.hashCode()
154         result = 31 * result + glowDrawPadding.hashCode()
155         return result
156     }
157 }
158 
159 @RequiresApi(Build.VERSION_CODES.S)
160 private class StretchOverscrollNode(
161     pointerInputNode: DelegatableNode,
162     private val overscrollEffect: AndroidEdgeEffectOverscrollEffect,
163     private val edgeEffectWrapper: EdgeEffectWrapper,
164 ) : DelegatingNode(), DrawModifierNode {
165     init {
166         delegate(pointerInputNode)
167     }
168 
169     /**
170      * There is an unwanted behavior in the stretch overscroll effect we have to workaround: when
171      * the effect is started it is getting the current RenderNode bounds and clips the content by
172      * those bounds. Even if this RenderNode is not configured to do clipping. Or if it clips, but
173      * not within its bounds, but by the outline provided which could have a completely different
174      * bounds. That is what happens with our scrolling containers - they all clip by the rect which
175      * is larger than the RenderNode bounds in order to not clip the shadows drawn in the cross axis
176      * of the scrolling direction. This issue is not that visible in the Views world because Views
177      * do clip by default. So adding one more clip doesn't change much. Thus why the whole shadows
178      * mechanism in the Views world works differently, the shadows are drawn not in-place, but with
179      * the background of the first parent which has a background.
180      *
181      * To solve this we need to render into a larger area, either by creating a larger layer for the
182      * child to draw in, or by manually rendering the stretch into a larger RenderNode, and then
183      * drawing that RenderNode into the existing layer. The difficulty here is that we only want to
184      * extend the cross axis / clip the main axis (scrolling containers do this already), otherwise
185      * the extra layer space will be transformed by the stretch, which results in an incorrect
186      * effect that can also end up revealing content underneath the scrolling container, as we
187      * stretch the transparent pixels in the extra space. For this to work we would need to know the
188      * stretch direction at layer creation time (i.e, placeWithLayer inside placement), but
189      * [OverscrollEffect] has no knowledge of directionality until an event is received. Creating a
190      * larger layer in this way is also more expensive and requires more parts, as we have to use
191      * two layout modifiers to achieve the desired effect.
192      *
193      * As a result we instead create a RenderNode that we extend in the cross-axis direction by
194      * [MaxSupportedElevation] on each side, to allow for non-clipped space without affecting layer
195      * size. We then draw the content (translated to be centered) and apply the stretch into this
196      * larger RenderNode, and then draw the RenderNode back into the original canvas (translated
197      * back to balance the previous translation), allowing for any shadows / other content drawn
198      * outside the cross-axis bounds to be unclipped by the RenderNode stretch.
199      */
200     private var _renderNode: RenderNode? = null
201     private val renderNode
202         get() =
<lambda>null203             _renderNode ?: RenderNode("AndroidEdgeEffectOverscrollEffect").also { _renderNode = it }
204 
205     @Suppress("KotlinConstantConditions")
drawnull206     override fun ContentDrawScope.draw() {
207         overscrollEffect.updateSize(size)
208         val canvas = drawContext.canvas.nativeCanvas
209         overscrollEffect.redrawSignal.value // <-- value read to redraw if needed
210         if (size.isEmpty()) {
211             // Draw any out of bounds content
212             drawContent()
213             return
214         }
215         // Render node / stretch effect is not supported in software rendering, so end the effect
216         // and draw the content normally - this is what happens in EdgeEffect.
217         if (!canvas.isHardwareAccelerated) {
218             edgeEffectWrapper.finishAll()
219             drawContent()
220             return
221         }
222         val maxElevation = MaxSupportedElevation.toPx()
223         var needsInvalidate = false
224         with(edgeEffectWrapper) {
225             val shouldDrawVerticalStretch = shouldDrawVerticalStretch()
226             val shouldDrawHorizontalStretch = shouldDrawHorizontalStretch()
227             when {
228                 // Drawing in both directions, so we need to match canvas size and essentially clip
229                 // both directions. We don't need the renderNode in this case, but it would
230                 // complicate the rest of the drawing logic.
231                 shouldDrawVerticalStretch && shouldDrawHorizontalStretch ->
232                     renderNode.setPosition(0, 0, canvas.width, canvas.height)
233                 // Drawing vertical stretch, so expand the width to prevent clipping
234                 shouldDrawVerticalStretch ->
235                     renderNode.setPosition(
236                         0,
237                         0,
238                         canvas.width + (maxElevation.roundToInt() * 2),
239                         canvas.height
240                     )
241                 // Drawing horizontal stretch, so expand the height to prevent clipping
242                 shouldDrawHorizontalStretch ->
243                     renderNode.setPosition(
244                         0,
245                         0,
246                         canvas.width,
247                         canvas.height + (maxElevation.roundToInt() * 2)
248                     )
249                 // Not drawing any stretch, so early return - we can draw into the existing canvas
250                 else -> {
251                     drawContent()
252                     return
253                 }
254             }
255             val recordingCanvas = renderNode.beginRecording()
256             // Views call RenderNode.clearStretch() (@hide API) to reset the stretch as part of
257             // the draw pass. We can't call this API, so by default the stretch would just keep on
258             // increasing for each new delta. Instead, to work around this, we can effectively
259             // 'negate' the previously rendered stretch by applying it, rotated 180 degrees, which
260             // cancels out the stretch applied to the RenderNode by the real stretch. To do this,
261             // we pull the negated stretch by the distance of the real stretch amount in each draw
262             // frame. Then in the next draw frame, we draw the negated stretch first, and then
263             // finish it so we can pull it by the real effect's distance again.
264             // Note that `draw` here isn't really drawing anything, it's applying a stretch to the
265             // whole RenderNode, so we can't clip / translate the drawing region here.
266             if (isLeftNegationStretched()) {
267                 val leftEffectNegation = getOrCreateLeftEffectNegation()
268                 // Invert the stretch
269                 drawRightStretch(leftEffectNegation, recordingCanvas)
270                 leftEffectNegation.finish()
271             }
272             if (isLeftAnimating()) {
273                 val leftEffect = getOrCreateLeftEffect()
274                 needsInvalidate = drawLeftStretch(leftEffect, recordingCanvas) || needsInvalidate
275                 if (isLeftStretched()) {
276                     // Displacement isn't currently used in AOSP for stretch, but provide the same
277                     // displacement in case any OEMs have custom behavior.
278                     val displacementY = overscrollEffect.displacement().y
279                     getOrCreateLeftEffectNegation()
280                         .onPullDistanceCompat(leftEffect.distanceCompat, 1 - displacementY)
281                 }
282             }
283             if (isTopNegationStretched()) {
284                 val topEffectNegation = getOrCreateTopEffectNegation()
285                 // Invert the stretch
286                 drawBottomStretch(topEffectNegation, recordingCanvas)
287                 topEffectNegation.finish()
288             }
289             if (isTopAnimating()) {
290                 val topEffect = getOrCreateTopEffect()
291                 needsInvalidate = drawTopStretch(topEffect, recordingCanvas) || needsInvalidate
292                 if (isTopStretched()) {
293                     // Displacement isn't currently used in AOSP for stretch, but provide the same
294                     // displacement in case any OEMs have custom behavior.
295                     val displacementX = overscrollEffect.displacement().x
296                     getOrCreateTopEffectNegation()
297                         .onPullDistanceCompat(topEffect.distanceCompat, displacementX)
298                 }
299             }
300             if (isRightNegationStretched()) {
301                 val rightEffectNegation = getOrCreateRightEffectNegation()
302                 // Invert the stretch
303                 drawLeftStretch(rightEffectNegation, recordingCanvas)
304                 rightEffectNegation.finish()
305             }
306             if (isRightAnimating()) {
307                 val rightEffect = getOrCreateRightEffect()
308                 needsInvalidate = drawRightStretch(rightEffect, recordingCanvas) || needsInvalidate
309                 if (isRightStretched()) {
310                     // Displacement isn't currently used in AOSP for stretch, but provide the same
311                     // displacement in case any OEMs have custom behavior.
312                     val displacementY = overscrollEffect.displacement().y
313                     getOrCreateRightEffectNegation()
314                         .onPullDistanceCompat(rightEffect.distanceCompat, displacementY)
315                 }
316             }
317             if (isBottomNegationStretched()) {
318                 val bottomEffectNegation = getOrCreateBottomEffectNegation()
319                 // Invert the stretch
320                 drawTopStretch(bottomEffectNegation, recordingCanvas)
321                 bottomEffectNegation.finish()
322             }
323             if (isBottomAnimating()) {
324                 val bottomEffect = getOrCreateBottomEffect()
325                 needsInvalidate =
326                     drawBottomStretch(bottomEffect, recordingCanvas) || needsInvalidate
327                 if (isBottomStretched()) {
328                     // Displacement isn't currently used in AOSP for stretch, but provide the same
329                     // displacement in case any OEMs have custom behavior.
330                     val displacementX = overscrollEffect.displacement().x
331                     getOrCreateBottomEffectNegation()
332                         .onPullDistanceCompat(bottomEffect.distanceCompat, 1 - displacementX)
333                 }
334             }
335 
336             if (needsInvalidate) overscrollEffect.invalidateOverscroll()
337             // Render the content for ContentDrawScope into the RenderNode, using the same size
338             // provided by ContentDrawScope - we only want to prevent clipping, not actually
339             // change the size of the content.
340             // Since we expand the size of the RenderNode so we don't clip the cross-axis content,
341             // we need to re-center the content in the RenderNode.
342             // We 'clip' in the direction of the stretch, so in that case there is no extra space
343             // and hence no need to translate. Otherwise, add the extra space.
344             val left = if (shouldDrawHorizontalStretch) 0f else maxElevation
345             val top = if (shouldDrawVerticalStretch) 0f else maxElevation
346             val outerDraw = this@draw
347             with(outerDraw) {
348                 draw(this, this.layoutDirection, Canvas(recordingCanvas), size) {
349                     translate(left, top) {
350                         // Since the stretch effect isn't really 'drawn', but is just set on
351                         // the RenderNode, it doesn't really matter when we call this in terms of
352                         // draw ordering.
353                         outerDraw.drawContent()
354                     }
355                 }
356             }
357             renderNode.endRecording()
358             // Now we can draw the larger RenderNode inside the actual canvas - but we need to
359             // translate it back by the amount we previously offset by inside the larger RenderNode.
360             val restore = canvas.save()
361             canvas.translate(-left, -top)
362             canvas.drawRenderNode(renderNode)
363             canvas.restoreToCount(restore)
364         }
365     }
366 
shouldDrawVerticalStretchnull367     private fun shouldDrawVerticalStretch() =
368         with(edgeEffectWrapper) {
369             isTopAnimating() ||
370                 isTopNegationStretched() ||
371                 isBottomAnimating() ||
372                 isBottomNegationStretched()
373         }
374 
shouldDrawHorizontalStretchnull375     private fun shouldDrawHorizontalStretch() =
376         with(edgeEffectWrapper) {
377             isLeftAnimating() ||
378                 isLeftNegationStretched() ||
379                 isRightAnimating() ||
380                 isRightNegationStretched()
381         }
382 
drawLeftStretchnull383     private fun drawLeftStretch(left: EdgeEffect, canvas: NativeCanvas): Boolean {
384         return drawWithRotation(rotationDegrees = 270f, edgeEffect = left, canvas = canvas)
385     }
386 
drawTopStretchnull387     private fun drawTopStretch(top: EdgeEffect, canvas: NativeCanvas): Boolean {
388         return drawWithRotation(rotationDegrees = 0f, edgeEffect = top, canvas = canvas)
389     }
390 
drawRightStretchnull391     private fun drawRightStretch(right: EdgeEffect, canvas: NativeCanvas): Boolean {
392         return drawWithRotation(rotationDegrees = 90f, edgeEffect = right, canvas = canvas)
393     }
394 
drawBottomStretchnull395     private fun drawBottomStretch(bottom: EdgeEffect, canvas: NativeCanvas): Boolean {
396         return drawWithRotation(rotationDegrees = 180f, edgeEffect = bottom, canvas = canvas)
397     }
398 
drawWithRotationnull399     private fun drawWithRotation(
400         rotationDegrees: Float,
401         edgeEffect: EdgeEffect,
402         canvas: NativeCanvas
403     ): Boolean {
404         if (rotationDegrees == 0f) {
405             val needsInvalidate = edgeEffect.draw(canvas)
406             return needsInvalidate
407         }
408         val restore = canvas.save()
409         canvas.rotate(rotationDegrees)
410         val needsInvalidate = edgeEffect.draw(canvas)
411         canvas.restoreToCount(restore)
412         return needsInvalidate
413     }
414 }
415 
416 private class GlowOverscrollNode(
417     pointerInputNode: DelegatableNode,
418     private val overscrollEffect: AndroidEdgeEffectOverscrollEffect,
419     private val edgeEffectWrapper: EdgeEffectWrapper,
420     private val glowDrawPadding: PaddingValues,
421 ) : DelegatingNode(), DrawModifierNode {
422     init {
423         delegate(pointerInputNode)
424     }
425 
426     @Suppress("KotlinConstantConditions")
drawnull427     override fun ContentDrawScope.draw() {
428         overscrollEffect.updateSize(size)
429         if (size.isEmpty()) {
430             // Draw any out of bounds content
431             drawContent()
432             return
433         }
434         drawContent()
435         overscrollEffect.redrawSignal.value // <-- value read to redraw if needed
436         val canvas = drawContext.canvas.nativeCanvas
437         var needsInvalidate = false
438         with(edgeEffectWrapper) {
439             if (isLeftAnimating()) {
440                 val leftEffect = getOrCreateLeftEffect()
441                 needsInvalidate = drawLeftGlow(leftEffect, canvas) || needsInvalidate
442             }
443             if (isTopAnimating()) {
444                 val topEffect = getOrCreateTopEffect()
445                 needsInvalidate = drawTopGlow(topEffect, canvas) || needsInvalidate
446             }
447             if (isRightAnimating()) {
448                 val rightEffect = getOrCreateRightEffect()
449                 needsInvalidate = drawRightGlow(rightEffect, canvas) || needsInvalidate
450             }
451             if (isBottomAnimating()) {
452                 val bottomEffect = getOrCreateBottomEffect()
453                 needsInvalidate = drawBottomGlow(bottomEffect, canvas) || needsInvalidate
454             }
455             if (needsInvalidate) overscrollEffect.invalidateOverscroll()
456         }
457     }
458 
DrawScopenull459     private fun DrawScope.drawLeftGlow(left: EdgeEffect, canvas: NativeCanvas): Boolean {
460         val offset =
461             Offset(-size.height, glowDrawPadding.calculateLeftPadding(layoutDirection).toPx())
462         return drawWithRotationAndOffset(
463             rotationDegrees = 270f,
464             offset = offset,
465             edgeEffect = left,
466             canvas = canvas
467         )
468     }
469 
DrawScopenull470     private fun DrawScope.drawTopGlow(top: EdgeEffect, canvas: NativeCanvas): Boolean {
471         val offset = Offset(0f, glowDrawPadding.calculateTopPadding().toPx())
472         return drawWithRotationAndOffset(
473             rotationDegrees = 0f,
474             offset = offset,
475             edgeEffect = top,
476             canvas = canvas
477         )
478     }
479 
DrawScopenull480     private fun DrawScope.drawRightGlow(right: EdgeEffect, canvas: NativeCanvas): Boolean {
481         val width = size.width.roundToInt()
482         val rightPadding = glowDrawPadding.calculateRightPadding(layoutDirection)
483         val offset = Offset(0f, -width.toFloat() + rightPadding.toPx())
484         return drawWithRotationAndOffset(
485             rotationDegrees = 90f,
486             offset = offset,
487             edgeEffect = right,
488             canvas = canvas
489         )
490     }
491 
DrawScopenull492     private fun DrawScope.drawBottomGlow(bottom: EdgeEffect, canvas: NativeCanvas): Boolean {
493         val bottomPadding = glowDrawPadding.calculateBottomPadding().toPx()
494         val offset = Offset(-size.width, -size.height + bottomPadding)
495         return drawWithRotationAndOffset(
496             rotationDegrees = 180f,
497             offset = offset,
498             edgeEffect = bottom,
499             canvas = canvas
500         )
501     }
502 
drawWithRotationAndOffsetnull503     private fun drawWithRotationAndOffset(
504         rotationDegrees: Float,
505         offset: Offset,
506         edgeEffect: EdgeEffect,
507         canvas: NativeCanvas
508     ): Boolean {
509         val restore = canvas.save()
510         canvas.rotate(rotationDegrees)
511         canvas.translate(offset.x, offset.y)
512         val needsInvalidate = edgeEffect.draw(canvas)
513         canvas.restoreToCount(restore)
514         return needsInvalidate
515     }
516 }
517 
518 internal class AndroidEdgeEffectOverscrollEffect(
519     context: Context,
520     private val density: Density,
521     glowColor: Color,
522     glowDrawPadding: PaddingValues
523 ) : OverscrollEffect {
524     private var pointerPosition: Offset = Offset.Unspecified
525 
526     private val edgeEffectWrapper = EdgeEffectWrapper(context, glowColor = glowColor.toArgb())
527 
528     internal val redrawSignal = mutableStateOf(Unit, neverEqualPolicy())
529 
530     @VisibleForTesting internal var invalidationEnabled = true
531 
532     private var scrollCycleInProgress: Boolean = false
533 
applyToScrollnull534     override fun applyToScroll(
535         delta: Offset,
536         source: NestedScrollSource,
537         performScroll: (Offset) -> Offset
538     ): Offset {
539         // Early return
540         if (containerSize.isEmpty()) {
541             return performScroll(delta)
542         }
543 
544         if (!scrollCycleInProgress) {
545             // We are starting a new scroll cycle: if there is an active stretch, we want to
546             // 'catch' it at its current point so that the user continues to manipulate the stretch
547             // with this new scroll, instead of letting the old stretch fade away underneath the
548             // user's input. To do this we pull with 0 offset, to put the stretch back into a
549             // 'pull' state, without changing its distance.
550             if (edgeEffectWrapper.isLeftStretched()) pullLeft(Offset.Zero)
551             if (edgeEffectWrapper.isRightStretched()) pullRight(Offset.Zero)
552             if (edgeEffectWrapper.isTopStretched()) pullTop(Offset.Zero)
553             if (edgeEffectWrapper.isBottomStretched()) pullBottom(Offset.Zero)
554             scrollCycleInProgress = true
555         }
556         // Relax existing stretches if needed before performing scroll. If this is happening inside
557         // a fling, we relax faster than normal.
558         val destretchMultiplier = destretchMultiplier(source)
559         val destretchDelta = delta * destretchMultiplier
560         val consumedPixelsY =
561             when {
562                 delta.y == 0f -> 0f
563                 edgeEffectWrapper.isTopStretched() && delta.y < 0f -> {
564                     val consumed =
565                         pullTop(destretchDelta).also {
566                             // Reset state if we have fully relaxed the stretch
567                             if (!edgeEffectWrapper.isTopStretched()) {
568                                 edgeEffectWrapper.getOrCreateTopEffect().finish()
569                             }
570                         }
571                     // Avoid rounding / float errors from dividing if all the delta was consumed
572                     if (consumed == destretchDelta.y) delta.y else consumed / destretchMultiplier
573                 }
574                 edgeEffectWrapper.isBottomStretched() && delta.y > 0f -> {
575                     val consumed =
576                         pullBottom(destretchDelta).also {
577                             // Reset state if we have fully relaxed the stretch
578                             if (!edgeEffectWrapper.isBottomStretched()) {
579                                 edgeEffectWrapper.getOrCreateBottomEffect().finish()
580                             }
581                         }
582                     // Avoid rounding / float errors from dividing if all the delta was consumed
583                     if (consumed == destretchDelta.y) delta.y else consumed / destretchMultiplier
584                 }
585                 else -> 0f
586             }
587         val consumedPixelsX =
588             when {
589                 delta.x == 0f -> 0f
590                 edgeEffectWrapper.isLeftStretched() && delta.x < 0f -> {
591                     val consumed =
592                         pullLeft(destretchDelta).also {
593                             // Reset state if we have fully relaxed the stretch
594                             if (!edgeEffectWrapper.isLeftStretched()) {
595                                 edgeEffectWrapper.getOrCreateLeftEffect().finish()
596                             }
597                         }
598                     // Avoid rounding / float errors from dividing if all the delta was consumed
599                     if (consumed == destretchDelta.x) delta.x else consumed / destretchMultiplier
600                 }
601                 edgeEffectWrapper.isRightStretched() && delta.x > 0f -> {
602                     val consumed =
603                         pullRight(destretchDelta).also {
604                             // Reset state if we have fully relaxed the stretch
605                             if (!edgeEffectWrapper.isRightStretched()) {
606                                 edgeEffectWrapper.getOrCreateRightEffect().finish()
607                             }
608                         }
609                     // Avoid rounding / float errors from dividing if all the delta was consumed
610                     if (consumed == destretchDelta.x) delta.x else consumed / destretchMultiplier
611                 }
612                 else -> 0f
613             }
614         val consumedOffset = Offset(consumedPixelsX, consumedPixelsY)
615         if (consumedOffset != Offset.Zero) invalidateOverscroll()
616 
617         val leftForDelta = delta - consumedOffset
618         val consumedByDelta = performScroll(leftForDelta)
619         val leftForOverscroll = leftForDelta - consumedByDelta
620 
621         // If there was some delta available for scrolling (we aren't consuming delta to relax),
622         // scrolling consumed some of this delta, and we are stretched, this means that the scroll
623         // started to consume again after previously not consuming. This can happen for example when
624         // a new item was added to the end of the list, so we want to release the stretch and let
625         // scrolling continue to happen without the stretch being 'stuck'. We compare x and y values
626         // individually to avoid issues due to Offset(-0,0) != Offset(0,0)
627         if (
628             (leftForDelta.x != 0f || leftForDelta.y != 0f) &&
629                 (consumedByDelta.x != 0f || consumedByDelta.y != 0f)
630         ) {
631             with(edgeEffectWrapper) {
632                 if (
633                     isLeftStretched() ||
634                         isTopStretched() ||
635                         isRightStretched() ||
636                         isBottomStretched()
637                 ) {
638                     animateToReleaseIfNeeded()
639                 }
640             }
641         }
642 
643         var needsInvalidation = false
644         if (source == NestedScrollSource.UserInput) {
645             // Ignore small deltas (< 0.5) as this usually comes from floating point rounding issues
646             // and can cause scrolling to lock up (b/265363356)
647             val appliedHorizontalOverscroll =
648                 if (leftForOverscroll.x > 0.5f) {
649                     pullLeft(leftForOverscroll)
650                     true
651                 } else if (leftForOverscroll.x < -0.5f) {
652                     pullRight(leftForOverscroll)
653                     true
654                 } else {
655                     false
656                 }
657             val appliedVerticalOverscroll =
658                 if (leftForOverscroll.y > 0.5f) {
659                     pullTop(leftForOverscroll)
660                     true
661                 } else if (leftForOverscroll.y < -0.5f) {
662                     pullBottom(leftForOverscroll)
663                     true
664                 } else {
665                     false
666                 }
667             needsInvalidation = appliedHorizontalOverscroll || appliedVerticalOverscroll
668         }
669 
670         // If we have leftover delta (overscroll didn't consume), release any glow effects in the
671         // opposite direction. This is only relevant for glow, as stretch effects will relax in
672         // pre-scroll, hence we check leftForDelta - this will be zero if the stretch effect is
673         // consuming in pre-scroll.
674         if (leftForDelta != Offset.Zero) {
675             needsInvalidation = releaseOppositeOverscroll(delta) || needsInvalidation
676         }
677         if (needsInvalidation) invalidateOverscroll()
678 
679         return consumedOffset + consumedByDelta
680     }
681 
applyToFlingnull682     override suspend fun applyToFling(
683         velocity: Velocity,
684         performFling: suspend (Velocity) -> Velocity
685     ) {
686         // Early return
687         if (containerSize.isEmpty()) {
688             performFling(velocity)
689             return
690         }
691         // Relax existing stretches before performing fling
692         val consumedX =
693             if (edgeEffectWrapper.isLeftStretched() && velocity.x < 0f) {
694                 edgeEffectWrapper
695                     .getOrCreateLeftEffect()
696                     .absorbToRelaxIfNeeded(velocity.x, containerSize.width, density)
697             } else if (edgeEffectWrapper.isRightStretched() && velocity.x > 0f) {
698                 -edgeEffectWrapper
699                     .getOrCreateRightEffect()
700                     .absorbToRelaxIfNeeded(-velocity.x, containerSize.width, density)
701             } else {
702                 0f
703             }
704         val consumedY =
705             if (edgeEffectWrapper.isTopStretched() && velocity.y < 0f) {
706                 edgeEffectWrapper
707                     .getOrCreateTopEffect()
708                     .absorbToRelaxIfNeeded(velocity.y, containerSize.height, density)
709             } else if (edgeEffectWrapper.isBottomStretched() && velocity.y > 0f) {
710                 -edgeEffectWrapper
711                     .getOrCreateBottomEffect()
712                     .absorbToRelaxIfNeeded(-velocity.y, containerSize.height, density)
713             } else {
714                 0f
715             }
716         val consumed = Velocity(consumedX, consumedY)
717         if (consumed != Velocity.Zero) invalidateOverscroll()
718 
719         val remainingVelocity = velocity - consumed
720         val consumedByVelocity = performFling(remainingVelocity)
721         val leftForOverscroll = remainingVelocity - consumedByVelocity
722 
723         scrollCycleInProgress = false
724         // Stretch with any leftover velocity
725         if (leftForOverscroll.x > 0) {
726             edgeEffectWrapper
727                 .getOrCreateLeftEffect()
728                 .onAbsorbCompat(leftForOverscroll.x.roundToInt())
729         } else if (leftForOverscroll.x < 0) {
730             edgeEffectWrapper
731                 .getOrCreateRightEffect()
732                 .onAbsorbCompat(-leftForOverscroll.x.roundToInt())
733         }
734         if (leftForOverscroll.y > 0) {
735             edgeEffectWrapper
736                 .getOrCreateTopEffect()
737                 .onAbsorbCompat(leftForOverscroll.y.roundToInt())
738         } else if (leftForOverscroll.y < 0) {
739             edgeEffectWrapper
740                 .getOrCreateBottomEffect()
741                 .onAbsorbCompat(-leftForOverscroll.y.roundToInt())
742         }
743         // Release any remaining effects, and invalidate if needed.
744         // For stretch this should only have an effect when velocity is exactly 0, since then the
745         // effects above will not be absorbed.
746         // For glow we don't absorb if we are already showing a glow from a drag
747         // (see onAbsorbCompat), so we need to manually release in this case as well.
748         animateToReleaseIfNeeded()
749     }
750 
751     private var containerSize = Size.Zero
752 
753     override val isInProgress: Boolean
754         get() {
<lambda>null755             edgeEffectWrapper.forEachEffect { if (it.distanceCompat != 0f) return true }
756             return false
757         }
758 
updateSizenull759     internal fun updateSize(size: Size) {
760         val initialSetSize = containerSize == Size.Zero
761         val differentSize = size != containerSize
762         containerSize = size
763         if (differentSize) {
764             edgeEffectWrapper.updateSize(IntSize(size.width.roundToInt(), size.height.roundToInt()))
765         }
766         if (!initialSetSize && differentSize) {
767             animateToReleaseIfNeeded()
768         }
769     }
770 
771     private var pointerId: PointerId = PointerId(-1L)
772 
773     /** @return displacement based on the last [pointerPosition] and [containerSize] */
displacementnull774     internal fun displacement(): Offset {
775         val pointer = if (pointerPosition.isSpecified) pointerPosition else containerSize.center
776         val x = pointer.x / containerSize.width
777         val y = pointer.y / containerSize.height
778         return Offset(x, y)
779     }
780 
<lambda>null781     private val pointerInputNode = SuspendingPointerInputModifierNode {
782         awaitEachGesture {
783             val down = awaitFirstDown(requireUnconsumed = false)
784             pointerId = down.id
785             pointerPosition = down.position
786             do {
787                 val pressedChanges = awaitPointerEvent().changes.fastFilter { it.pressed }
788                 // If the same ID we are already tracking is down, use that. Otherwise, use
789                 // the next down, to move the overscroll to the next pointer.
790                 val change =
791                     pressedChanges.fastFirstOrNull { it.id == pointerId }
792                         ?: pressedChanges.firstOrNull()
793                 if (change != null) {
794                     // Update the id if we are now tracking a new down
795                     pointerId = change.id
796                     pointerPosition = change.position
797                 }
798             } while (pressedChanges.isNotEmpty())
799             pointerId = PointerId(-1L)
800             // Explicitly not resetting the pointer position until the next down, so we
801             // don't change any existing effects
802         }
803     }
804 
805     override val node =
806         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
807             StretchOverscrollNode(
808                 pointerInputNode,
809                 this@AndroidEdgeEffectOverscrollEffect,
810                 edgeEffectWrapper,
811             )
812         } else {
813             GlowOverscrollNode(
814                 pointerInputNode,
815                 this@AndroidEdgeEffectOverscrollEffect,
816                 edgeEffectWrapper,
817                 glowDrawPadding
818             )
819         }
820 
invalidateOverscrollnull821     internal fun invalidateOverscroll() {
822         if (invalidationEnabled) {
823             // TODO: b/367437728 replace with invalidateDraw()
824             redrawSignal.value = Unit
825         }
826     }
827 
828     /**
829      * Animate any pulled edge effects to 0 / resets overscroll. If an edge effect is already
830      * receding, onRelease will no-op. Invalidates any still active edge effects.
831      */
animateToReleaseIfNeedednull832     private fun animateToReleaseIfNeeded() {
833         var needsInvalidation = false
834         edgeEffectWrapper.forEachEffect {
835             it.onRelease()
836             needsInvalidation = !it.isFinished || needsInvalidation
837         }
838         if (needsInvalidation) invalidateOverscroll()
839     }
840 
841     /**
842      * Releases overscroll effects in the opposite direction to the current scroll [delta]. E.g.,
843      * when scrolling down, the top glow will show - if the user starts to scroll up, we need to
844      * release the existing top glow as we are no longer overscrolling in that direction.
845      *
846      * @return whether invalidation is needed (we released an animating edge effect)
847      */
releaseOppositeOverscrollnull848     private fun releaseOppositeOverscroll(delta: Offset): Boolean {
849         var needsInvalidation = false
850         if (edgeEffectWrapper.isLeftAnimating() && delta.x < 0) {
851             edgeEffectWrapper.getOrCreateLeftEffect().onReleaseWithOppositeDelta(delta = delta.x)
852             needsInvalidation = edgeEffectWrapper.isLeftAnimating()
853         }
854         if (edgeEffectWrapper.isRightAnimating() && delta.x > 0) {
855             edgeEffectWrapper.getOrCreateRightEffect().onReleaseWithOppositeDelta(delta = delta.x)
856             needsInvalidation = needsInvalidation || edgeEffectWrapper.isRightAnimating()
857         }
858         if (edgeEffectWrapper.isTopAnimating() && delta.y < 0) {
859             edgeEffectWrapper.getOrCreateTopEffect().onReleaseWithOppositeDelta(delta = delta.y)
860             needsInvalidation = needsInvalidation || edgeEffectWrapper.isTopAnimating()
861         }
862         if (edgeEffectWrapper.isBottomAnimating() && delta.y > 0) {
863             edgeEffectWrapper.getOrCreateBottomEffect().onReleaseWithOppositeDelta(delta = delta.y)
864             needsInvalidation = needsInvalidation || edgeEffectWrapper.isBottomAnimating()
865         }
866         return needsInvalidation
867     }
868 
pullTopnull869     private fun pullTop(scroll: Offset): Float {
870         val displacementX = displacement().x
871         val pullY = scroll.y / containerSize.height
872         val topEffect = edgeEffectWrapper.getOrCreateTopEffect()
873         val consumed = topEffect.onPullDistanceCompat(pullY, displacementX) * containerSize.height
874         // If overscroll is showing, assume we have consumed all the provided scroll, and return
875         // that amount directly to avoid floating point rounding issues (b/265363356)
876         return if (topEffect.distanceCompat != 0f) {
877             scroll.y
878         } else {
879             consumed
880         }
881     }
882 
pullBottomnull883     private fun pullBottom(scroll: Offset): Float {
884         val displacementX = displacement().x
885         val pullY = scroll.y / containerSize.height
886         val bottomEffect = edgeEffectWrapper.getOrCreateBottomEffect()
887         val consumed =
888             -bottomEffect.onPullDistanceCompat(-pullY, 1 - displacementX) * containerSize.height
889         // If overscroll is showing, assume we have consumed all the provided scroll, and return
890         // that amount directly to avoid floating point rounding issues (b/265363356)
891         return if (bottomEffect.distanceCompat != 0f) {
892             scroll.y
893         } else {
894             consumed
895         }
896     }
897 
pullLeftnull898     private fun pullLeft(scroll: Offset): Float {
899         val displacementY = displacement().y
900         val pullX = scroll.x / containerSize.width
901         val leftEffect = edgeEffectWrapper.getOrCreateLeftEffect()
902         val consumed =
903             leftEffect.onPullDistanceCompat(pullX, 1 - displacementY) * containerSize.width
904         // If overscroll is showing, assume we have consumed all the provided scroll, and return
905         // that amount directly to avoid floating point rounding issues (b/265363356)
906         return if (leftEffect.distanceCompat != 0f) {
907             scroll.x
908         } else {
909             consumed
910         }
911     }
912 
pullRightnull913     private fun pullRight(scroll: Offset): Float {
914         val displacementY = displacement().y
915         val pullX = scroll.x / containerSize.width
916         val rightEffect = edgeEffectWrapper.getOrCreateRightEffect()
917         val consumed =
918             -rightEffect.onPullDistanceCompat(-pullX, displacementY) * containerSize.width
919         // If overscroll is showing, assume we have consumed all the provided scroll, and return
920         // that amount directly to avoid floating point rounding issues (b/265363356)
921         return if (rightEffect.distanceCompat != 0f) {
922             scroll.x
923         } else {
924             consumed
925         }
926     }
927 }
928 
929 /** Handles lazy creation of [EdgeEffect]s used to render overscroll. */
930 private class EdgeEffectWrapper(
931     private val context: Context,
932     @ColorInt private val glowColor: Int
933 ) {
934     private var size: IntSize = IntSize.Zero
935     private var topEffect: EdgeEffect? = null
936     private var bottomEffect: EdgeEffect? = null
937     private var leftEffect: EdgeEffect? = null
938     private var rightEffect: EdgeEffect? = null
939 
940     // These are used to negate the previous stretch, since RenderNode#clearStretch() is not public
941     // API. See DrawStretchOverscrollModifier for more information.
942     private var topEffectNegation: EdgeEffect? = null
943     private var bottomEffectNegation: EdgeEffect? = null
944     private var leftEffectNegation: EdgeEffect? = null
945     private var rightEffectNegation: EdgeEffect? = null
946 
forEachEffectnull947     inline fun forEachEffect(action: (EdgeEffect) -> Unit) {
948         topEffect?.let(action)
949         bottomEffect?.let(action)
950         leftEffect?.let(action)
951         rightEffect?.let(action)
952     }
953 
954     /** Immediately finishes / resets all effects (and corresponding negations) */
finishAllnull955     fun finishAll() {
956         topEffect?.finish()
957         bottomEffect?.finish()
958         leftEffect?.finish()
959         rightEffect?.finish()
960         topEffectNegation?.finish()
961         bottomEffectNegation?.finish()
962         leftEffectNegation?.finish()
963         rightEffectNegation?.finish()
964     }
965 
isTopStretchednull966     fun isTopStretched(): Boolean = topEffect.isStretched
967 
968     fun isBottomStretched(): Boolean = bottomEffect.isStretched
969 
970     fun isLeftStretched(): Boolean = leftEffect.isStretched
971 
972     fun isRightStretched(): Boolean = rightEffect.isStretched
973 
974     fun isTopNegationStretched(): Boolean = topEffectNegation.isStretched
975 
976     fun isBottomNegationStretched(): Boolean = bottomEffectNegation.isStretched
977 
978     fun isLeftNegationStretched(): Boolean = leftEffectNegation.isStretched
979 
980     fun isRightNegationStretched(): Boolean = rightEffectNegation.isStretched
981 
982     private val EdgeEffect?.isStretched: Boolean
983         get() {
984             if (this == null) return false
985             return distanceCompat != 0f
986         }
987 
isTopAnimatingnull988     fun isTopAnimating(): Boolean = topEffect.isAnimating
989 
990     fun isBottomAnimating(): Boolean = bottomEffect.isAnimating
991 
992     fun isLeftAnimating(): Boolean = leftEffect.isAnimating
993 
994     fun isRightAnimating(): Boolean = rightEffect.isAnimating
995 
996     private val EdgeEffect?.isAnimating: Boolean
997         get() {
998             if (this == null) return false
999             return !isFinished
1000         }
1001 
getOrCreateTopEffectnull1002     fun getOrCreateTopEffect(): EdgeEffect =
1003         topEffect ?: createEdgeEffect(Orientation.Vertical).also { topEffect = it }
1004 
getOrCreateBottomEffectnull1005     fun getOrCreateBottomEffect(): EdgeEffect =
1006         bottomEffect ?: createEdgeEffect(Orientation.Vertical).also { bottomEffect = it }
1007 
getOrCreateLeftEffectnull1008     fun getOrCreateLeftEffect(): EdgeEffect =
1009         leftEffect ?: createEdgeEffect(Orientation.Horizontal).also { leftEffect = it }
1010 
getOrCreateRightEffectnull1011     fun getOrCreateRightEffect(): EdgeEffect =
1012         rightEffect ?: createEdgeEffect(Orientation.Horizontal).also { rightEffect = it }
1013 
getOrCreateTopEffectNegationnull1014     fun getOrCreateTopEffectNegation(): EdgeEffect =
1015         topEffectNegation ?: createEdgeEffect(Orientation.Vertical).also { topEffectNegation = it }
1016 
getOrCreateBottomEffectNegationnull1017     fun getOrCreateBottomEffectNegation(): EdgeEffect =
1018         bottomEffectNegation
1019             ?: createEdgeEffect(Orientation.Vertical).also { bottomEffectNegation = it }
1020 
getOrCreateLeftEffectNegationnull1021     fun getOrCreateLeftEffectNegation(): EdgeEffect =
1022         leftEffectNegation
1023             ?: createEdgeEffect(Orientation.Horizontal).also { leftEffectNegation = it }
1024 
getOrCreateRightEffectNegationnull1025     fun getOrCreateRightEffectNegation(): EdgeEffect =
1026         rightEffectNegation
1027             ?: createEdgeEffect(Orientation.Horizontal).also { rightEffectNegation = it }
1028 
createEdgeEffectnull1029     private fun createEdgeEffect(orientation: Orientation) =
1030         EdgeEffectCompat.create(context).apply {
1031             color = glowColor
1032             if (size != IntSize.Zero) {
1033                 if (orientation == Orientation.Vertical) {
1034                     setSize(size.width, size.height)
1035                 } else {
1036                     setSize(size.height, size.width)
1037                 }
1038             }
1039         }
1040 
updateSizenull1041     fun updateSize(size: IntSize) {
1042         this.size = size
1043         topEffect?.setSize(size.width, size.height)
1044         bottomEffect?.setSize(size.width, size.height)
1045         leftEffect?.setSize(size.height, size.width)
1046         rightEffect?.setSize(size.height, size.width)
1047 
1048         topEffectNegation?.setSize(size.width, size.height)
1049         bottomEffectNegation?.setSize(size.width, size.height)
1050         leftEffectNegation?.setSize(size.height, size.width)
1051         rightEffectNegation?.setSize(size.height, size.width)
1052     }
1053 }
1054 
1055 /**
1056  * When we are destretching inside a scroll that is caused by a fling
1057  * ([NestedScrollSource.SideEffect]), we want to destretch quicker than normal. See
1058  * [FlingDestretchFactor].
1059  */
destretchMultipliernull1060 private fun destretchMultiplier(source: NestedScrollSource): Float =
1061     if (source == NestedScrollSource.SideEffect) FlingDestretchFactor else 1f
1062 
1063 /**
1064  * When flinging the stretch towards scrolling content, it should destretch quicker than the fling
1065  * would normally do. The visual effect of flinging the stretch looks strange as little appears to
1066  * happen at first and then when the stretch disappears, the content starts scrolling quickly.
1067  */
1068 private const val FlingDestretchFactor = 4f
1069 
1070 /** From [EdgeEffect] defaults */
1071 private val DefaultGlowColor = Color(0xff666666)
1072 private val DefaultGlowPaddingValues = PaddingValues()
1073