react-native-smooth-clip-view 0.4.6 → 0.5.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +47 -0
  3. package/android/src/main/cpp/SmoothClipAndroid.h +22 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +37 -3
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -2
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +18 -0
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropView.kt +59 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropViewManager.kt +63 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +15 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +316 -34
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +19 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +1 -1
  13. package/cpp/SmoothClipAnimationCurve.h +36 -11
  14. package/cpp/SmoothClipRegistry.h +11 -4
  15. package/ios/SmoothClipBackdropView.h +17 -0
  16. package/ios/SmoothClipBackdropView.mm +267 -0
  17. package/ios/SmoothClipRegistry.mm +192 -9
  18. package/ios/SmoothClipTurboModule.cpp +33 -11
  19. package/ios/SmoothClipView.mm +723 -22
  20. package/ios/SmoothClipViewRegistry.h +14 -0
  21. package/lib/module/SmoothClipBackdropView.js +40 -0
  22. package/lib/module/SmoothClipBackdropView.js.map +1 -0
  23. package/lib/module/SmoothClipBackdropViewNativeComponent.ts +15 -0
  24. package/lib/module/SmoothClipView.ios.js +17 -1
  25. package/lib/module/SmoothClipView.ios.js.map +1 -1
  26. package/lib/module/SmoothClipViewNativeComponent.ts +4 -0
  27. package/lib/module/geometry.js +44 -6
  28. package/lib/module/geometry.js.map +1 -1
  29. package/lib/module/groups.native.js +10 -3
  30. package/lib/module/groups.native.js.map +1 -1
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/presentationCodec.js +6 -2
  34. package/lib/module/presentationCodec.js.map +1 -1
  35. package/lib/typescript/src/SmoothClipBackdropView.d.ts +155 -0
  36. package/lib/typescript/src/SmoothClipBackdropView.d.ts.map +1 -0
  37. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts +8 -0
  38. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/src/SmoothClipView.android.d.ts +1 -1
  40. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -1
  41. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  42. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  43. package/lib/typescript/src/SmoothClipView.ios.d.ts +15 -1
  44. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  45. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  46. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  47. package/lib/typescript/src/geometry.d.ts +19 -0
  48. package/lib/typescript/src/geometry.d.ts.map +1 -1
  49. package/lib/typescript/src/groups.native.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +4 -2
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  53. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  54. package/package.json +4 -1
  55. package/src/SmoothClipBackdropView.tsx +53 -0
  56. package/src/SmoothClipBackdropViewNativeComponent.ts +15 -0
  57. package/src/SmoothClipView.android.tsx +4 -1
  58. package/src/SmoothClipView.ios.tsx +24 -1
  59. package/src/SmoothClipView.tsx +4 -1
  60. package/src/SmoothClipViewNativeComponent.ts +4 -0
  61. package/src/geometry.ts +59 -4
  62. package/src/groups.native.ts +9 -3
  63. package/src/index.ts +7 -1
  64. 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.reset()
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 (requestedShadowEnabled && !clipIsEmpty &&
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.reset()
628
+ clipPath.rewind()
508
629
  if (!isEmpty) {
509
- if (curveCode == CLIP_CURVE_CIRCULAR && radiiAreUniform) {
510
- clipPath.addRoundRect(
511
- left,
512
- top,
513
- right,
514
- bottom,
515
- topLeftRadius,
516
- topLeftRadius,
517
- Path.Direction.CW,
518
- )
519
- } else {
520
- appendRoundedRectPath(
521
- clipPath,
522
- left,
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.reset()
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 thirteen channels are geometry/content/appearance; the final eight are shadow.
142
- // Curve and shadow presence are categorical and stay outside the scalar array.
143
- constexpr std::size_t kBaseChannelCount = 13;
144
- constexpr std::size_t kChannelCount = 21;
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
- std::max(0.0, channels[19]),
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], clamp01(channels[12])};
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(
@@ -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, platforms that ignore it): the integrator then falls back to its
77
- // own clock plus the min() frame-clock anchor. iOS constructs this struct
78
- // without the member and inherits the NaN default — CoreAnimation anchors
79
- // its own animations, so the hint is Android-only by design.
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