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