react-native-smooth-clip-view 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +47 -0
- package/android/src/main/cpp/SmoothClipAndroid.h +22 -0
- package/android/src/main/cpp/SmoothClipBindings.cpp +37 -3
- package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -2
- package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +18 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipBackdropView.kt +59 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipBackdropViewManager.kt +63 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +15 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +316 -34
- package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +19 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +1 -1
- package/cpp/SmoothClipAnimationCurve.h +36 -11
- package/cpp/SmoothClipRegistry.h +11 -4
- package/ios/SmoothClipBackdropView.h +17 -0
- package/ios/SmoothClipBackdropView.mm +267 -0
- package/ios/SmoothClipRegistry.mm +192 -9
- package/ios/SmoothClipTurboModule.cpp +33 -11
- package/ios/SmoothClipView.mm +713 -22
- package/ios/SmoothClipViewRegistry.h +14 -0
- package/lib/module/SmoothClipBackdropView.js +40 -0
- package/lib/module/SmoothClipBackdropView.js.map +1 -0
- package/lib/module/SmoothClipBackdropViewNativeComponent.ts +15 -0
- package/lib/module/SmoothClipView.ios.js +17 -1
- package/lib/module/SmoothClipView.ios.js.map +1 -1
- package/lib/module/SmoothClipViewNativeComponent.ts +4 -0
- package/lib/module/geometry.js +44 -6
- package/lib/module/geometry.js.map +1 -1
- package/lib/module/groups.native.js +10 -3
- package/lib/module/groups.native.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/presentationCodec.js +6 -2
- package/lib/module/presentationCodec.js.map +1 -1
- package/lib/typescript/src/SmoothClipBackdropView.d.ts +155 -0
- package/lib/typescript/src/SmoothClipBackdropView.d.ts.map +1 -0
- package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts +8 -0
- package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/SmoothClipView.android.d.ts +1 -1
- package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -1
- package/lib/typescript/src/SmoothClipView.d.ts +1 -1
- package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
- package/lib/typescript/src/SmoothClipView.ios.d.ts +15 -1
- package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
- package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
- package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/geometry.d.ts +19 -0
- package/lib/typescript/src/geometry.d.ts.map +1 -1
- package/lib/typescript/src/groups.native.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/presentationCodec.d.ts +1 -1
- package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/SmoothClipBackdropView.tsx +53 -0
- package/src/SmoothClipBackdropViewNativeComponent.ts +15 -0
- package/src/SmoothClipView.android.tsx +4 -1
- package/src/SmoothClipView.ios.tsx +24 -1
- package/src/SmoothClipView.tsx +4 -1
- package/src/SmoothClipViewNativeComponent.ts +4 -0
- package/src/geometry.ts +59 -4
- package/src/groups.native.ts +9 -3
- package/src/index.ts +7 -1
- package/src/presentationCodec.ts +8 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.smoothclipview
|
|
2
2
|
|
|
3
3
|
import android.content.res.Configuration
|
|
4
|
+
import android.graphics.Bitmap
|
|
4
5
|
import android.graphics.BlurMaskFilter
|
|
5
6
|
import android.graphics.Canvas
|
|
6
7
|
import android.graphics.Color
|
|
@@ -8,8 +9,14 @@ import android.graphics.Outline
|
|
|
8
9
|
import android.graphics.Matrix
|
|
9
10
|
import android.graphics.Paint
|
|
10
11
|
import android.graphics.Path
|
|
12
|
+
import android.graphics.Rect
|
|
13
|
+
import android.graphics.RectF
|
|
11
14
|
import android.graphics.Region
|
|
12
15
|
import android.os.Build
|
|
16
|
+
import android.os.Handler
|
|
17
|
+
import android.os.HandlerThread
|
|
18
|
+
import android.os.Looper
|
|
19
|
+
import android.os.Process
|
|
13
20
|
import android.os.Trace
|
|
14
21
|
import android.view.MotionEvent
|
|
15
22
|
import android.view.View
|
|
@@ -18,6 +25,8 @@ import com.facebook.proguard.annotations.DoNotStrip
|
|
|
18
25
|
import com.facebook.react.uimanager.PixelUtil
|
|
19
26
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
20
27
|
import com.facebook.react.views.view.ReactViewGroup
|
|
28
|
+
import java.lang.ref.WeakReference
|
|
29
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
21
30
|
import kotlin.math.roundToInt
|
|
22
31
|
import kotlin.math.abs
|
|
23
32
|
import kotlin.math.pow
|
|
@@ -87,6 +96,18 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
87
96
|
private var clipCurveCode = CLIP_CURVE_CIRCULAR
|
|
88
97
|
private var boxShadowPath: Path? = null
|
|
89
98
|
private var boxShadowPaint: Paint? = null
|
|
99
|
+
// `shadowRendering="baked"`: draw the shadow from one pre-blurred tile per
|
|
100
|
+
// radius step, stretched in nine pieces, instead of blurring a path on
|
|
101
|
+
// every frame the aperture moves. Like iOS, the tile is drawn under the
|
|
102
|
+
// aperture too (the blur path cuts it out); content is expected opaque.
|
|
103
|
+
private var bakedShadows = false
|
|
104
|
+
private var bakedShadowTile: BakedShadowTile? = null
|
|
105
|
+
private var bakedShadowPaint: Paint? = null
|
|
106
|
+
private val bakedSrc = Rect()
|
|
107
|
+
private val bakedDst = RectF()
|
|
108
|
+
private val bakedXs = FloatArray(4)
|
|
109
|
+
private val bakedYs = FloatArray(4)
|
|
110
|
+
private val bakedSx = IntArray(4)
|
|
90
111
|
private var clipIsEmpty = true
|
|
91
112
|
private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
92
113
|
private var autonomousMotion = false
|
|
@@ -410,10 +431,51 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
410
431
|
return (radius + spread * multiplier).coerceAtLeast(0f)
|
|
411
432
|
}
|
|
412
433
|
|
|
434
|
+
fun setBakedShadows(enabled: Boolean) {
|
|
435
|
+
if (bakedShadows == enabled) return
|
|
436
|
+
bakedShadows = enabled
|
|
437
|
+
if (!enabled) bakedShadowTile = null
|
|
438
|
+
if (requestedShadowEnabled && requestedShadowAlpha > 0f) {
|
|
439
|
+
rebuildBoxShadowPath()
|
|
440
|
+
presentationContainer.invalidate()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private fun usesBakedShadow(): Boolean {
|
|
445
|
+
if (!bakedShadows || clipTopLeftRadius != clipTopRightRadius ||
|
|
446
|
+
clipTopLeftRadius != clipBottomRightRadius ||
|
|
447
|
+
clipTopLeftRadius != clipBottomLeftRadius
|
|
448
|
+
) {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
// A shape too small for two corner pieces on a side, 2 x (margin +
|
|
452
|
+
// radius), keeps the blur path: overlapping pieces do not add up to
|
|
453
|
+
// the blur tails the true shadow overlaps.
|
|
454
|
+
val margin = kotlin.math.ceil(1.5f * requestedShadowBlurRadius.coerceAtLeast(0f))
|
|
455
|
+
val minimumSide = 2f * (margin + bakedShadowTileRadius())
|
|
456
|
+
val spread = 2f * requestedShadowSpreadDistance
|
|
457
|
+
return clipRight - clipLeft + spread >= minimumSide &&
|
|
458
|
+
clipBottom - clipTop + spread >= minimumSide
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Radius step, in px, of the tile the current shadow draws from. */
|
|
462
|
+
private fun bakedShadowTileRadius(): Float {
|
|
463
|
+
val radius = adjustedRadiusForSpread(clipTopLeftRadius, requestedShadowSpreadDistance)
|
|
464
|
+
val step = BAKED_SHADOW_RADIUS_STEP_DP * resources.displayMetrics.density
|
|
465
|
+
return if (step > 0f) kotlin.math.ceil(radius / step) * step else radius
|
|
466
|
+
}
|
|
467
|
+
|
|
413
468
|
private fun rebuildBoxShadowPath() {
|
|
414
469
|
if (!requestedShadowEnabled || requestedShadowAlpha <= 0f) return
|
|
470
|
+
if (usesBakedShadow()) {
|
|
471
|
+
// The tile replaces the path; a stale path must not be drawn if
|
|
472
|
+
// the radii later stop being uniform before a rebuild.
|
|
473
|
+
boxShadowPath?.rewind()
|
|
474
|
+
return
|
|
475
|
+
}
|
|
415
476
|
val path = boxShadowPath ?: Path().also { boxShadowPath = it }
|
|
416
|
-
path
|
|
477
|
+
// Keeps the path's storage across frames; reset() may free it.
|
|
478
|
+
path.rewind()
|
|
417
479
|
if (clipIsEmpty) return
|
|
418
480
|
val spread = requestedShadowSpreadDistance
|
|
419
481
|
val left = clipLeft - spread + requestedShadowOffsetX
|
|
@@ -445,13 +507,75 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
445
507
|
}
|
|
446
508
|
}
|
|
447
509
|
|
|
510
|
+
private fun bakedShadowTile(): BakedShadowTile? {
|
|
511
|
+
val density = resources.displayMetrics.density
|
|
512
|
+
val step = BAKED_SHADOW_RADIUS_STEP_DP * density
|
|
513
|
+
val tileRadius = bakedShadowTileRadius()
|
|
514
|
+
val band = maxOf(2, (2f * density).roundToInt())
|
|
515
|
+
val rgb = Color.rgb(
|
|
516
|
+
(requestedShadowRed * 255f).roundToInt(),
|
|
517
|
+
(requestedShadowGreen * 255f).roundToInt(),
|
|
518
|
+
(requestedShadowBlue * 255f).roundToInt(),
|
|
519
|
+
)
|
|
520
|
+
val current = bakedShadowTile
|
|
521
|
+
if (current != null && current.matches(tileRadius, clipCurveCode, requestedShadowBlurRadius, rgb)) {
|
|
522
|
+
return current
|
|
523
|
+
}
|
|
524
|
+
// A stand-in from a neighbouring step does not match, so the next
|
|
525
|
+
// frame asks again and picks up the exact tile once it has landed.
|
|
526
|
+
return BakedShadowTiles.getOrRequest(
|
|
527
|
+
tileRadius, clipCurveCode, requestedShadowBlurRadius, rgb, band, step, this,
|
|
528
|
+
).also { bakedShadowTile = it }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The exact tile for the current radius step has landed: draw it. */
|
|
532
|
+
internal fun shadowTileDidLand() {
|
|
533
|
+
if (!requestedShadowEnabled || requestedShadowAlpha <= 0f || !usesBakedShadow()) return
|
|
534
|
+
presentationContainer.invalidate()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private fun drawBakedBoxShadow(canvas: Canvas) {
|
|
538
|
+
val tile = bakedShadowTile() ?: return
|
|
539
|
+
val spread = requestedShadowSpreadDistance
|
|
540
|
+
val margin = tile.margin.toFloat()
|
|
541
|
+
val left = clipLeft - spread + requestedShadowOffsetX - margin
|
|
542
|
+
val top = clipTop - spread + requestedShadowOffsetY - margin
|
|
543
|
+
val right = clipRight + spread + requestedShadowOffsetX + margin
|
|
544
|
+
val bottom = clipBottom + spread + requestedShadowOffsetY + margin
|
|
545
|
+
if (right <= left || bottom <= top) return
|
|
546
|
+
val paint = bakedShadowPaint ?: Paint(Paint.FILTER_BITMAP_FLAG).also {
|
|
547
|
+
bakedShadowPaint = it
|
|
548
|
+
}
|
|
549
|
+
paint.alpha = (requestedShadowAlpha * 255f).roundToInt().coerceIn(0, 255)
|
|
550
|
+
val corner = tile.corner.toFloat()
|
|
551
|
+
// Nine pieces: corners keep their size, edges stretch one way, the
|
|
552
|
+
// centre both ways. A destination too small for two corners draws
|
|
553
|
+
// them overlapping and skips the middle pieces.
|
|
554
|
+
bakedXs[0] = left; bakedXs[1] = left + corner
|
|
555
|
+
bakedXs[2] = right - corner; bakedXs[3] = right
|
|
556
|
+
bakedYs[0] = top; bakedYs[1] = top + corner
|
|
557
|
+
bakedYs[2] = bottom - corner; bakedYs[3] = bottom
|
|
558
|
+
bakedSx[0] = 0; bakedSx[1] = tile.corner
|
|
559
|
+
bakedSx[2] = tile.corner + tile.band; bakedSx[3] = tile.side
|
|
560
|
+
for (row in 0 until 3) {
|
|
561
|
+
for (col in 0 until 3) {
|
|
562
|
+
bakedDst.set(bakedXs[col], bakedYs[row], bakedXs[col + 1], bakedYs[row + 1])
|
|
563
|
+
if (bakedDst.right <= bakedDst.left || bakedDst.bottom <= bakedDst.top) continue
|
|
564
|
+
bakedSrc.set(bakedSx[col], bakedSx[row], bakedSx[col + 1], bakedSx[row + 1])
|
|
565
|
+
canvas.drawBitmap(tile.bitmap, bakedSrc, bakedDst, paint)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
448
570
|
private fun drawBoxShadow(canvas: Canvas) {
|
|
571
|
+
if (!requestedShadowEnabled || clipIsEmpty || requestedShadowAlpha <= 0f) return
|
|
572
|
+
if (usesBakedShadow()) {
|
|
573
|
+
drawBakedBoxShadow(canvas)
|
|
574
|
+
return
|
|
575
|
+
}
|
|
449
576
|
val shadowPath = boxShadowPath
|
|
450
577
|
val shadowPaint = boxShadowPaint
|
|
451
|
-
if (
|
|
452
|
-
requestedShadowAlpha > 0f && shadowPath != null &&
|
|
453
|
-
shadowPaint != null && !shadowPath.isEmpty
|
|
454
|
-
) {
|
|
578
|
+
if (shadowPath != null && shadowPaint != null && !shadowPath.isEmpty) {
|
|
455
579
|
val apertureSaveCount = canvas.save()
|
|
456
580
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
457
581
|
canvas.clipOutPath(clipPath)
|
|
@@ -487,9 +611,6 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
487
611
|
clipTop = top
|
|
488
612
|
clipRight = right
|
|
489
613
|
clipBottom = bottom
|
|
490
|
-
val radiiAreUniform = topLeftRadius == topRightRadius &&
|
|
491
|
-
topLeftRadius == bottomRightRadius &&
|
|
492
|
-
topLeftRadius == bottomLeftRadius
|
|
493
614
|
clipTopLeftRadius = topLeftRadius
|
|
494
615
|
clipTopRightRadius = topRightRadius
|
|
495
616
|
clipBottomRightRadius = bottomRightRadius
|
|
@@ -504,32 +625,22 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
504
625
|
if (!geometryChanged) return
|
|
505
626
|
|
|
506
627
|
updateOverflowInsets()
|
|
507
|
-
clipPath.
|
|
628
|
+
clipPath.rewind()
|
|
508
629
|
if (!isEmpty) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
top,
|
|
524
|
-
right,
|
|
525
|
-
bottom,
|
|
526
|
-
topLeftRadius,
|
|
527
|
-
topRightRadius,
|
|
528
|
-
bottomRightRadius,
|
|
529
|
-
bottomLeftRadius,
|
|
530
|
-
curveCode,
|
|
531
|
-
)
|
|
532
|
-
}
|
|
630
|
+
// One builder for clip, shadow and hit testing; it keeps a uniform
|
|
631
|
+
// circular corner as an rrect for the renderer's fast paths.
|
|
632
|
+
appendRoundedRectPath(
|
|
633
|
+
clipPath,
|
|
634
|
+
left,
|
|
635
|
+
top,
|
|
636
|
+
right,
|
|
637
|
+
bottom,
|
|
638
|
+
topLeftRadius,
|
|
639
|
+
topRightRadius,
|
|
640
|
+
bottomRightRadius,
|
|
641
|
+
bottomLeftRadius,
|
|
642
|
+
curveCode,
|
|
643
|
+
)
|
|
533
644
|
}
|
|
534
645
|
rebuildBoxShadowPath()
|
|
535
646
|
if (supportsPathOutlineClipping) {
|
|
@@ -800,9 +911,180 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
|
800
911
|
requestedShadowSpreadDistance = 0f
|
|
801
912
|
boxShadowPaint = null
|
|
802
913
|
boxShadowPath = null
|
|
914
|
+
bakedShadowTile = null
|
|
803
915
|
applyContentTransform()
|
|
804
|
-
clipPath.
|
|
916
|
+
clipPath.rewind()
|
|
805
917
|
requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
806
918
|
applyRequestedGeometry()
|
|
807
919
|
}
|
|
808
920
|
}
|
|
921
|
+
|
|
922
|
+
/** Corner-radius step baked shadow tiles are rounded up to, in dp. */
|
|
923
|
+
internal const val BAKED_SHADOW_RADIUS_STEP_DP = 4f
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* One pre-blurred rounded-rect shadow, square, with the shape inset by
|
|
927
|
+
* [margin] (3σ = 1.5 × the CSS blur) on every side and a stretchable band of
|
|
928
|
+
* [band] px between the corner regions of [corner] px. Colour is opaque; the
|
|
929
|
+
* shadow alpha is applied by the drawing paint.
|
|
930
|
+
*/
|
|
931
|
+
internal class BakedShadowTile(
|
|
932
|
+
val bitmap: Bitmap,
|
|
933
|
+
val radius: Float,
|
|
934
|
+
val curveCode: Int,
|
|
935
|
+
val blurRadius: Float,
|
|
936
|
+
val rgb: Int,
|
|
937
|
+
val margin: Int,
|
|
938
|
+
val band: Int,
|
|
939
|
+
) {
|
|
940
|
+
val corner: Int = 2 * margin + radius.roundToInt()
|
|
941
|
+
val side: Int = bitmap.width
|
|
942
|
+
|
|
943
|
+
fun matches(radius: Float, curveCode: Int, blurRadius: Float, rgb: Int): Boolean =
|
|
944
|
+
this.radius == radius && this.curveCode == curveCode &&
|
|
945
|
+
this.blurRadius == blurRadius && this.rgb == rgb
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Process-wide tile cache: a handful of (radius step, curve, blur, colour).
|
|
950
|
+
*
|
|
951
|
+
* A bake costs a bitmap allocation plus a software blur, growing with
|
|
952
|
+
* blur² × density², so it stays off the frame: a missing tile is requested on
|
|
953
|
+
* a background thread while the nearest cached step stands in, and only a
|
|
954
|
+
* cold cache (nothing near to show, at mount) bakes on the calling thread.
|
|
955
|
+
*/
|
|
956
|
+
internal object BakedShadowTiles {
|
|
957
|
+
private const val MAX_TILES = 12
|
|
958
|
+
/** Radius steps searched on either side of a missing tile for a stand-in. */
|
|
959
|
+
private const val SEARCH_STEPS = 16
|
|
960
|
+
private val tiles = LinkedHashMap<String, BakedShadowTile>(MAX_TILES, 0.75f, true)
|
|
961
|
+
private val pending = LinkedHashMap<String, PendingTile>()
|
|
962
|
+
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
|
|
963
|
+
private val worker by lazy {
|
|
964
|
+
val thread = HandlerThread("SmoothClipShadowTiles", Process.THREAD_PRIORITY_BACKGROUND)
|
|
965
|
+
thread.start()
|
|
966
|
+
Handler(thread.looper)
|
|
967
|
+
}
|
|
968
|
+
/** Where a requested bake runs and where its result lands; tests swap in queues they drain. */
|
|
969
|
+
internal var runBake: (Runnable) -> Unit = { worker.post(it) }
|
|
970
|
+
internal var runOnMain: (Runnable) -> Unit = { mainHandler.post(it) }
|
|
971
|
+
/** Tiles baked so far in this process; tests read it. */
|
|
972
|
+
val bakeCount = AtomicInteger()
|
|
973
|
+
|
|
974
|
+
private class PendingTile(
|
|
975
|
+
val radius: Float,
|
|
976
|
+
val curveCode: Int,
|
|
977
|
+
val blurRadius: Float,
|
|
978
|
+
val rgb: Int,
|
|
979
|
+
val band: Int,
|
|
980
|
+
) {
|
|
981
|
+
val requesters = mutableListOf<WeakReference<SmoothClipView>>()
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private fun key(radius: Float, curveCode: Int, blurRadius: Float, rgb: Int, band: Int): String =
|
|
985
|
+
"$radius|$curveCode|$blurRadius|$rgb|$band"
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* The tile for this radius step when cached. Otherwise the nearest cached
|
|
989
|
+
* step (the rounder neighbour first: a tile corner tighter than the clip
|
|
990
|
+
* corner over it reads as a dark sliver, a rounder one only recedes under
|
|
991
|
+
* the clip) while the exact tile bakes on the background thread, after
|
|
992
|
+
* which [requester] is asked to draw again. Nothing near: baked now.
|
|
993
|
+
*/
|
|
994
|
+
@Synchronized
|
|
995
|
+
fun getOrRequest(
|
|
996
|
+
radius: Float,
|
|
997
|
+
curveCode: Int,
|
|
998
|
+
blurRadius: Float,
|
|
999
|
+
rgb: Int,
|
|
1000
|
+
band: Int,
|
|
1001
|
+
stepPx: Float,
|
|
1002
|
+
requester: SmoothClipView?,
|
|
1003
|
+
): BakedShadowTile? {
|
|
1004
|
+
val key = key(radius, curveCode, blurRadius, rgb, band)
|
|
1005
|
+
tiles[key]?.let { return it }
|
|
1006
|
+
val standIn = if (stepPx > 0f) nearest(radius, curveCode, blurRadius, rgb, band, stepPx) else null
|
|
1007
|
+
if (standIn == null) {
|
|
1008
|
+
return bake(radius, curveCode, blurRadius, rgb, band)?.also { store(key, it) }
|
|
1009
|
+
}
|
|
1010
|
+
request(key, PendingTile(radius, curveCode, blurRadius, rgb, band), requester)
|
|
1011
|
+
return standIn
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private fun nearest(
|
|
1015
|
+
radius: Float,
|
|
1016
|
+
curveCode: Int,
|
|
1017
|
+
blurRadius: Float,
|
|
1018
|
+
rgb: Int,
|
|
1019
|
+
band: Int,
|
|
1020
|
+
stepPx: Float,
|
|
1021
|
+
): BakedShadowTile? {
|
|
1022
|
+
for (distance in 1..SEARCH_STEPS) {
|
|
1023
|
+
val delta = distance * stepPx
|
|
1024
|
+
tiles[key(radius + delta, curveCode, blurRadius, rgb, band)]?.let { return it }
|
|
1025
|
+
if (radius - delta < 0f) continue
|
|
1026
|
+
tiles[key(radius - delta, curveCode, blurRadius, rgb, band)]?.let { return it }
|
|
1027
|
+
}
|
|
1028
|
+
return null
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private fun store(key: String, tile: BakedShadowTile) {
|
|
1032
|
+
tiles[key] = tile
|
|
1033
|
+
if (tiles.size > MAX_TILES) {
|
|
1034
|
+
val eldest = tiles.entries.iterator()
|
|
1035
|
+
eldest.next()
|
|
1036
|
+
eldest.remove()
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private fun request(key: String, tile: PendingTile, requester: SmoothClipView?) {
|
|
1041
|
+
val existing = pending[key]
|
|
1042
|
+
if (existing != null) {
|
|
1043
|
+
// One entry per view: a stand-in never matches, so the same view
|
|
1044
|
+
// asks again on every draw until the exact tile lands.
|
|
1045
|
+
if (requester != null && existing.requesters.none { it.get() === requester }) {
|
|
1046
|
+
existing.requesters += WeakReference(requester)
|
|
1047
|
+
}
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
if (requester != null) tile.requesters += WeakReference(requester)
|
|
1051
|
+
pending[key] = tile
|
|
1052
|
+
runBake {
|
|
1053
|
+
val baked = bake(tile.radius, tile.curveCode, tile.blurRadius, tile.rgb, tile.band)
|
|
1054
|
+
runOnMain { land(key, baked) }
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private fun land(key: String, tile: BakedShadowTile?) {
|
|
1059
|
+
val entry = synchronized(this) {
|
|
1060
|
+
val removed = pending.remove(key)
|
|
1061
|
+
if (tile != null && removed != null) store(key, tile)
|
|
1062
|
+
removed
|
|
1063
|
+
}
|
|
1064
|
+
if (tile == null || entry == null) return
|
|
1065
|
+
for (requester in entry.requesters) requester.get()?.shadowTileDidLand()
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
private fun bake(radius: Float, curveCode: Int, blurRadius: Float, rgb: Int, band: Int): BakedShadowTile? {
|
|
1069
|
+
val margin = kotlin.math.ceil(1.5f * blurRadius.coerceAtLeast(0f)).toInt()
|
|
1070
|
+
val corner = 2 * margin + radius.roundToInt()
|
|
1071
|
+
val side = 2 * corner + band
|
|
1072
|
+
if (side <= 0 || side > 4096) return null
|
|
1073
|
+
bakeCount.incrementAndGet()
|
|
1074
|
+
val bitmap = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888)
|
|
1075
|
+
val path = Path()
|
|
1076
|
+
val inset = margin.toFloat()
|
|
1077
|
+
appendRoundedRectPath(
|
|
1078
|
+
path, inset, inset, side - inset, side - inset,
|
|
1079
|
+
radius, radius, radius, radius, curveCode,
|
|
1080
|
+
)
|
|
1081
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
1082
|
+
paint.color = rgb or (0xFF shl 24)
|
|
1083
|
+
val sigma = blurRadius * 0.5f
|
|
1084
|
+
if (sigma > 0.5f) {
|
|
1085
|
+
paint.maskFilter = BlurMaskFilter((sigma - 0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL)
|
|
1086
|
+
}
|
|
1087
|
+
Canvas(bitmap).drawPath(path, paint)
|
|
1088
|
+
return BakedShadowTile(bitmap, radius, curveCode, blurRadius, rgb, margin, band)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
@@ -39,6 +39,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
|
|
|
39
39
|
var shadowSpreadDistance: Double = 0.0,
|
|
40
40
|
var rotation: Double = 0.0,
|
|
41
41
|
var opacity: Double = 1.0,
|
|
42
|
+
var backdropTranslateX: Double = 0.0,
|
|
43
|
+
var backdropTranslateY: Double = 0.0,
|
|
42
44
|
var driverId: Double = 0.0,
|
|
43
45
|
// Whether any prop setter ran in the current update transaction.
|
|
44
46
|
var dirty: Boolean = false,
|
|
@@ -181,6 +183,21 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
|
|
|
181
183
|
@ReactProp(name = "initialOpacity", defaultDouble = 1.0)
|
|
182
184
|
override fun setInitialOpacity(view: SmoothClipView, value: Double) { pending(view).opacity = value }
|
|
183
185
|
|
|
186
|
+
@ReactProp(name = "shadowRendering", defaultInt = 0)
|
|
187
|
+
override fun setShadowRendering(view: SmoothClipView, value: Int) {
|
|
188
|
+
view.setBakedShadows(value == 1)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@ReactProp(name = "initialBackdropTranslateX", defaultDouble = 0.0)
|
|
192
|
+
override fun setInitialBackdropTranslateX(view: SmoothClipView, value: Double) {
|
|
193
|
+
pending(view).backdropTranslateX = value
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@ReactProp(name = "initialBackdropTranslateY", defaultDouble = 0.0)
|
|
197
|
+
override fun setInitialBackdropTranslateY(view: SmoothClipView, value: Double) {
|
|
198
|
+
pending(view).backdropTranslateY = value
|
|
199
|
+
}
|
|
200
|
+
|
|
184
201
|
override fun setClipPresentation(
|
|
185
202
|
view: SmoothClipView,
|
|
186
203
|
x: Double,
|
|
@@ -276,6 +293,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
|
|
|
276
293
|
initial.shadowSpreadDistance,
|
|
277
294
|
initial.rotation,
|
|
278
295
|
initial.opacity,
|
|
296
|
+
initial.backdropTranslateX,
|
|
297
|
+
initial.backdropTranslateY,
|
|
279
298
|
view.densityScale(),
|
|
280
299
|
view.width.toDouble(),
|
|
281
300
|
view.height.toDouble(),
|
|
@@ -10,7 +10,7 @@ import com.facebook.react.uimanager.ViewManager
|
|
|
10
10
|
class SmoothClipViewPackage : BaseReactPackage() {
|
|
11
11
|
override fun createViewManagers(
|
|
12
12
|
reactContext: ReactApplicationContext,
|
|
13
|
-
): List<ViewManager<*, *>> = listOf(SmoothClipViewManager())
|
|
13
|
+
): List<ViewManager<*, *>> = listOf(SmoothClipViewManager(), SmoothClipBackdropViewManager())
|
|
14
14
|
|
|
15
15
|
override fun getModule(
|
|
16
16
|
name: String,
|
|
@@ -138,10 +138,11 @@ inline double cubicBezier(
|
|
|
138
138
|
|
|
139
139
|
// --- Presentation channels ------------------------------------------------
|
|
140
140
|
|
|
141
|
-
// The first
|
|
142
|
-
// Curve and shadow presence are categorical and stay
|
|
143
|
-
|
|
144
|
-
constexpr std::size_t
|
|
141
|
+
// The first fifteen channels are geometry/content/appearance/backdrop; the
|
|
142
|
+
// final eight are shadow. Curve and shadow presence are categorical and stay
|
|
143
|
+
// outside the scalar array.
|
|
144
|
+
constexpr std::size_t kBaseChannelCount = 15;
|
|
145
|
+
constexpr std::size_t kChannelCount = 23;
|
|
145
146
|
using Channels = std::array<double, kChannelCount>;
|
|
146
147
|
|
|
147
148
|
inline double resolvedRadius(double overrideValue, double shorthand) {
|
|
@@ -179,6 +180,8 @@ inline Channels toChannels(const Presentation &presentation) {
|
|
|
179
180
|
presentation.contentScale,
|
|
180
181
|
presentation.rotation,
|
|
181
182
|
presentation.opacity,
|
|
183
|
+
presentation.backdropTranslateX,
|
|
184
|
+
presentation.backdropTranslateY,
|
|
182
185
|
presentation.shadow.red,
|
|
183
186
|
presentation.shadow.green,
|
|
184
187
|
presentation.shadow.blue,
|
|
@@ -365,6 +368,27 @@ inline double relativeSpringEnergy(
|
|
|
365
368
|
return currentEnergy / initialEnergy;
|
|
366
369
|
}
|
|
367
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Seconds until the normalized spring trajectory started with `velocity`
|
|
373
|
+
* settles under the animation's relative-energy threshold, sampled at 120 Hz
|
|
374
|
+
* and capped at ten seconds. Every layer of a run (clip, content, shadow,
|
|
375
|
+
* backdrop) derives its Core Animation duration from this one rule, so they
|
|
376
|
+
* are removed in the same frame.
|
|
377
|
+
*/
|
|
378
|
+
inline double springSettleDuration(
|
|
379
|
+
const SpringAnimation &animation,
|
|
380
|
+
double velocity) {
|
|
381
|
+
ScalarSpringState state{0, velocity};
|
|
382
|
+
constexpr double step = 1.0 / 120.0;
|
|
383
|
+
double elapsed = 0;
|
|
384
|
+
while (elapsed < 10.0 &&
|
|
385
|
+
relativeSpringEnergy(state, animation) > animation.energyThreshold) {
|
|
386
|
+
state = advanceScalarSpring(state, animation, step);
|
|
387
|
+
elapsed += step;
|
|
388
|
+
}
|
|
389
|
+
return elapsed;
|
|
390
|
+
}
|
|
391
|
+
|
|
368
392
|
inline bool springScaleStaysPositive(
|
|
369
393
|
const Presentation &start,
|
|
370
394
|
const Presentation &target,
|
|
@@ -460,16 +484,17 @@ inline Presentation fromChannels(
|
|
|
460
484
|
geometry.curve = curve;
|
|
461
485
|
Shadow shadow{
|
|
462
486
|
shadowEnabled,
|
|
463
|
-
std::clamp(channels[13], 0.0, 1.0),
|
|
464
|
-
std::clamp(channels[14], 0.0, 1.0),
|
|
465
487
|
std::clamp(channels[15], 0.0, 1.0),
|
|
466
488
|
std::clamp(channels[16], 0.0, 1.0),
|
|
467
|
-
channels[17],
|
|
468
|
-
channels[18],
|
|
469
|
-
|
|
470
|
-
channels[20]
|
|
489
|
+
std::clamp(channels[17], 0.0, 1.0),
|
|
490
|
+
std::clamp(channels[18], 0.0, 1.0),
|
|
491
|
+
channels[19],
|
|
492
|
+
channels[20],
|
|
493
|
+
std::max(0.0, channels[21]),
|
|
494
|
+
channels[22]};
|
|
471
495
|
return Presentation{
|
|
472
|
-
geometry, channels[8], channels[9], channels[10], shadow, channels[11],
|
|
496
|
+
geometry, channels[8], channels[9], channels[10], shadow, channels[11],
|
|
497
|
+
clamp01(channels[12]), channels[13], channels[14]};
|
|
473
498
|
}
|
|
474
499
|
|
|
475
500
|
inline Presentation interpolate(
|
package/cpp/SmoothClipRegistry.h
CHANGED
|
@@ -46,6 +46,12 @@ struct Presentation {
|
|
|
46
46
|
Shadow shadow{};
|
|
47
47
|
double rotation = 0; // Unwrapped radians, clockwise in host coordinates.
|
|
48
48
|
double opacity = 1;
|
|
49
|
+
// Translation of every SmoothClipBackdropView bound to the driver, in
|
|
50
|
+
// host points. A channel of the same run as the aperture, so content that
|
|
51
|
+
// must stay locked to the window (a canvas centred in it) is sampled by
|
|
52
|
+
// the same native clock as the clip and never lands a frame late.
|
|
53
|
+
double backdropTranslateX = 0;
|
|
54
|
+
double backdropTranslateY = 0;
|
|
49
55
|
};
|
|
50
56
|
|
|
51
57
|
struct TimingAnimation {
|
|
@@ -73,10 +79,11 @@ struct AnimationStart {
|
|
|
73
79
|
// Reanimated-rule start stamp, captured on the UI runtime at animateTo time
|
|
74
80
|
// as `__frameTimestamp || _getAnimationTimestamp()` and converted to
|
|
75
81
|
// CLOCK_MONOTONIC seconds. NaN means no stamp was captured (older callers,
|
|
76
|
-
// tests
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
82
|
+
// tests): the integrator then falls back to its own clock plus the min()
|
|
83
|
+
// frame-clock anchor. iOS does not use this member: it carries the same
|
|
84
|
+
// stamp on the animation group (`GroupState::beginTimeHint` in
|
|
85
|
+
// SmoothClipRegistry.mm) and applies it as the shared Core Animation
|
|
86
|
+
// `beginTime`, bounded by the same sanity window.
|
|
80
87
|
double startedAtHintS = std::numeric_limits<double>::quiet_NaN();
|
|
81
88
|
};
|
|
82
89
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#import <React/RCTViewComponentView.h>
|
|
2
|
+
#import <UIKit/UIKit.h>
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A view translated by its controller's backdrop channel. The registry writes
|
|
8
|
+
* the channel to an inner content layer on every setFrame and adds a
|
|
9
|
+
* translation animation to every native run on the clip's shared Core
|
|
10
|
+
* Animation epoch, so the content is sampled by the same clock as the
|
|
11
|
+
* aperture and never lands a frame late. The view's own layer keeps React
|
|
12
|
+
* Native's transform.
|
|
13
|
+
*/
|
|
14
|
+
@interface SmoothClipBackdropView : RCTViewComponentView
|
|
15
|
+
@end
|
|
16
|
+
|
|
17
|
+
NS_ASSUME_NONNULL_END
|