react-native-smooth-clip-view 0.4.2 → 0.4.4

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 (46) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +74 -0
  3. package/android/src/main/cpp/SmoothClipAndroid.h +1 -1
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +14 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +6 -6
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +23 -0
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +2 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +2 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +124 -32
  10. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +16 -0
  11. package/cpp/SmoothClipAnimationCurve.h +13 -10
  12. package/cpp/SmoothClipRegistry.h +2 -0
  13. package/cpp/SmoothClipSharedGeometry.h +20 -0
  14. package/cpp/SmoothClipVelocityTracker.h +6 -0
  15. package/ios/SmoothClipTurboModule.cpp +17 -6
  16. package/ios/SmoothClipTurboModule.h +3 -1
  17. package/ios/SmoothClipView.mm +241 -22
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/SmoothClipView.ios.js +3 -0
  20. package/lib/module/SmoothClipView.ios.js.map +1 -1
  21. package/lib/module/SmoothClipViewNativeComponent.ts +5 -1
  22. package/lib/module/geometry.js +22 -2
  23. package/lib/module/geometry.js.map +1 -1
  24. package/lib/module/presentationCodec.js +5 -3
  25. package/lib/module/presentationCodec.js.map +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  27. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  28. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  29. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +3 -1
  30. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  31. package/lib/typescript/src/controllerTypes.d.ts +5 -1
  32. package/lib/typescript/src/controllerTypes.d.ts.map +1 -1
  33. package/lib/typescript/src/geometry.d.ts +9 -0
  34. package/lib/typescript/src/geometry.d.ts.map +1 -1
  35. package/lib/typescript/src/index.d.ts +1 -1
  36. package/lib/typescript/src/index.d.ts.map +1 -1
  37. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  38. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  39. package/package.json +5 -4
  40. package/src/NativeSmoothClipModule.ts +3 -1
  41. package/src/SmoothClipView.ios.tsx +3 -0
  42. package/src/SmoothClipViewNativeComponent.ts +5 -1
  43. package/src/controllerTypes.ts +5 -1
  44. package/src/geometry.ts +35 -0
  45. package/src/index.ts +1 -0
  46. package/src/presentationCodec.ts +8 -2
@@ -5,6 +5,7 @@ import android.graphics.BlurMaskFilter
5
5
  import android.graphics.Canvas
6
6
  import android.graphics.Color
7
7
  import android.graphics.Outline
8
+ import android.graphics.Matrix
8
9
  import android.graphics.Paint
9
10
  import android.graphics.Path
10
11
  import android.graphics.Region
@@ -20,6 +21,8 @@ import com.facebook.react.views.view.ReactViewGroup
20
21
  import kotlin.math.roundToInt
21
22
  import kotlin.math.abs
22
23
  import kotlin.math.pow
24
+ import kotlin.math.cos
25
+ import kotlin.math.sin
23
26
 
24
27
  class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
25
28
  private val clipPath = Path()
@@ -41,6 +44,16 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
41
44
  }
42
45
  }
43
46
  }
47
+ // One compositing group; arbitrary React children may overlap when faded.
48
+ internal val presentationContainer = object : ReactViewGroup(context) {
49
+ override fun hasOverlappingRendering(): Boolean = true
50
+ override fun dispatchDraw(canvas: Canvas) {
51
+ drawBoxShadow(canvas)
52
+ super.dispatchDraw(canvas)
53
+ }
54
+ }
55
+ private var requestedRotation = 0.0
56
+ private var requestedOpacity = 1f
44
57
  internal val contentContainer = ReactViewGroup(context)
45
58
  private var requestedX = 0f
46
59
  private var requestedY = 0f
@@ -78,6 +91,9 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
78
91
  private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
79
92
  private var autonomousMotion = false
80
93
  private var acceptingTouchStream = false
94
+ private var forwardingOverflowTouchStream = false
95
+ private val inversePresentationMatrix = Matrix()
96
+ private val touchPoint = FloatArray(2)
81
97
 
82
98
  /** Driver this view is registered with in the native registry (0 = none). */
83
99
  internal var boundDriverId: Double = 0.0
@@ -104,7 +120,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
104
120
 
105
121
  init {
106
122
  clipContainer.addView(contentContainer)
107
- super.addView(clipContainer)
123
+ presentationContainer.addView(clipContainer)
124
+ super.addView(presentationContainer)
108
125
  if (supportsPathOutlineClipping) {
109
126
  clipContainer.outlineProvider = clipOutlineProvider
110
127
  clipContainer.clipToOutline = true
@@ -139,6 +156,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
139
156
  shadowOffsetY: Double = 0.0,
140
157
  shadowBlurRadius: Double = 0.0,
141
158
  shadowSpreadDistance: Double = 0.0,
159
+ rotation: Double = 0.0,
160
+ opacity: Double = 1.0,
142
161
  ) {
143
162
  if (!x.isFinite() || !y.isFinite() || !width.isFinite() ||
144
163
  !height.isFinite() || !topLeftRadius.isFinite() ||
@@ -149,7 +168,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
149
168
  !shadowBlue.isFinite() || !shadowAlpha.isFinite() ||
150
169
  !shadowOffsetX.isFinite() ||
151
170
  !shadowOffsetY.isFinite() || !shadowBlurRadius.isFinite() ||
152
- !shadowSpreadDistance.isFinite() || contentScale <= 0.0 ||
171
+ !shadowSpreadDistance.isFinite() || !rotation.isFinite() || !opacity.isFinite() || contentScale <= 0.0 ||
153
172
  shadowRed !in 0.0..1.0 || shadowGreen !in 0.0..1.0 ||
154
173
  shadowBlue !in 0.0..1.0 || shadowAlpha !in 0.0..1.0 ||
155
174
  shadowBlurRadius < 0.0 ||
@@ -197,6 +216,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
197
216
  requestedCurveCode = curveCode
198
217
  requestedContentTranslateX = nextContentTranslateX
199
218
  requestedContentTranslateY = nextContentTranslateY
219
+ requestedRotation = rotation
220
+ requestedOpacity = opacity.coerceIn(0.0, 1.0).toFloat()
200
221
  requestedContentScale = nextContentScale
201
222
  storeShadow(
202
223
  shadowEnabled,
@@ -236,6 +257,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
236
257
  shadowOffsetY: Float = 0f,
237
258
  shadowBlurRadius: Float = 0f,
238
259
  shadowSpreadDistance: Float = 0f,
260
+ rotation: Double = 0.0,
261
+ opacity: Float = 1f,
239
262
  ) {
240
263
  if (!left.isFinite() || !top.isFinite() || !right.isFinite() ||
241
264
  !bottom.isFinite() || !topLeftRadius.isFinite() ||
@@ -246,7 +269,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
246
269
  !shadowBlue.isFinite() || !shadowAlpha.isFinite() ||
247
270
  !shadowOffsetX.isFinite() ||
248
271
  !shadowOffsetY.isFinite() || !shadowBlurRadius.isFinite() ||
249
- !shadowSpreadDistance.isFinite() || contentScale <= 0f ||
272
+ !shadowSpreadDistance.isFinite() || !rotation.isFinite() || !opacity.isFinite() || contentScale <= 0f ||
250
273
  shadowRed !in 0f..1f || shadowGreen !in 0f..1f ||
251
274
  shadowBlue !in 0f..1f || shadowAlpha !in 0f..1f ||
252
275
  shadowBlurRadius < 0f ||
@@ -259,6 +282,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
259
282
  try {
260
283
  requestedContentTranslateX = contentTranslateX
261
284
  requestedContentTranslateY = contentTranslateY
285
+ requestedRotation = rotation
286
+ requestedOpacity = opacity.coerceIn(0f, 1f)
262
287
  requestedContentScale = contentScale
263
288
  storeShadow(
264
289
  shadowEnabled,
@@ -342,14 +367,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
342
367
  requestedShadowBlurRadius = blurRadius
343
368
  requestedShadowSpreadDistance = spreadDistance
344
369
  if (!isVisible) {
345
- if (wasVisible) invalidate()
370
+ if (wasVisible) presentationContainer.invalidate()
346
371
  return
347
372
  }
348
373
  if (paintChanged || boxShadowPaint == null) updateBoxShadowPaint()
349
374
  if (pathChanged || !wasVisible || boxShadowPath == null) {
350
375
  rebuildBoxShadowPath()
351
376
  }
352
- if (paintChanged || pathChanged || !wasVisible) invalidate()
377
+ if (paintChanged || pathChanged || !wasVisible) presentationContainer.invalidate()
353
378
  }
354
379
 
355
380
  private fun updateBoxShadowPaint() {
@@ -414,28 +439,31 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
414
439
  val hostSaveCount = canvas.save()
415
440
  canvas.clipRect(0f, 0f, width.toFloat(), height.toFloat())
416
441
  try {
417
- val shadowPath = boxShadowPath
418
- val shadowPaint = boxShadowPaint
419
- if (requestedShadowEnabled && !clipIsEmpty &&
420
- requestedShadowAlpha > 0f && shadowPath != null &&
421
- shadowPaint != null && !shadowPath.isEmpty
422
- ) {
423
- val apertureSaveCount = canvas.save()
424
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
425
- canvas.clipOutPath(clipPath)
426
- } else {
427
- @Suppress("DEPRECATION")
428
- canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
429
- }
430
- canvas.drawPath(shadowPath, shadowPaint)
431
- canvas.restoreToCount(apertureSaveCount)
432
- }
433
442
  super.dispatchDraw(canvas)
434
443
  } finally {
435
444
  canvas.restoreToCount(hostSaveCount)
436
445
  }
437
446
  }
438
447
 
448
+ private fun drawBoxShadow(canvas: Canvas) {
449
+ val shadowPath = boxShadowPath
450
+ val shadowPaint = boxShadowPaint
451
+ if (requestedShadowEnabled && !clipIsEmpty &&
452
+ requestedShadowAlpha > 0f && shadowPath != null &&
453
+ shadowPaint != null && !shadowPath.isEmpty
454
+ ) {
455
+ val apertureSaveCount = canvas.save()
456
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
457
+ canvas.clipOutPath(clipPath)
458
+ } else {
459
+ @Suppress("DEPRECATION")
460
+ canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
461
+ }
462
+ canvas.drawPath(shadowPath, shadowPaint)
463
+ canvas.restoreToCount(apertureSaveCount)
464
+ }
465
+ }
466
+
439
467
  private fun applyCanonicalClipPx(
440
468
  left: Float,
441
469
  top: Float,
@@ -470,10 +498,12 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
470
498
  applyContentTransform()
471
499
 
472
500
  clipIsEmpty = isEmpty
501
+ applyPresentationTransform()
473
502
  reapplyClipPresentation()
474
503
 
475
504
  if (!geometryChanged) return
476
505
 
506
+ updateOverflowInsets()
477
507
  clipPath.reset()
478
508
  if (!isEmpty) {
479
509
  if (curveCode == CLIP_CURVE_CIRCULAR && radiiAreUniform) {
@@ -507,7 +537,29 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
507
537
  } else {
508
538
  clipContainer.invalidate()
509
539
  }
510
- if (requestedShadowEnabled) invalidate()
540
+ if (requestedShadowEnabled) presentationContainer.invalidate()
541
+ }
542
+
543
+ private fun updateOverflowInsets() {
544
+ // React Native's touch-target walk also needs to see the unrotated overflow.
545
+ val left = kotlin.math.floor(minOf(0f, clipLeft).toDouble()).toInt()
546
+ val top = kotlin.math.floor(minOf(0f, clipTop).toDouble()).toInt()
547
+ val right = kotlin.math.floor(minOf(0f, width - clipRight).toDouble()).toInt()
548
+ val bottom = kotlin.math.floor(minOf(0f, height - clipBottom).toDouble()).toInt()
549
+ presentationContainer.setOverflowInset(left, top, right, bottom)
550
+ clipContainer.setOverflowInset(left, top, right, bottom)
551
+ }
552
+
553
+ private fun applyPresentationTransform() {
554
+ // The pivot is immaterial at zero rotation; avoid dirtying a stationary parent.
555
+ val cx = if (requestedRotation == 0.0) 0f else (clipLeft + clipRight) / 2f
556
+ val cy = if (requestedRotation == 0.0) 0f else (clipTop + clipBottom) / 2f
557
+ // Keep unwrapped doubles in the registry. Only the render transform is periodic.
558
+ val degrees = Math.toDegrees(requestedRotation % (2 * Math.PI)).toFloat()
559
+ if (presentationContainer.pivotX != cx) presentationContainer.pivotX = cx
560
+ if (presentationContainer.pivotY != cy) presentationContainer.pivotY = cy
561
+ if (presentationContainer.rotation != degrees) presentationContainer.rotation = degrees
562
+ if (presentationContainer.alpha != requestedOpacity) presentationContainer.alpha = requestedOpacity
511
563
  }
512
564
 
513
565
  private fun applyContentTransform() {
@@ -525,8 +577,10 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
525
577
 
526
578
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
527
579
  super.onSizeChanged(w, h, oldw, oldh)
580
+ presentationContainer.layout(0, 0, w, h)
528
581
  clipContainer.layout(0, 0, w, h)
529
582
  contentContainer.layout(0, 0, w, h)
583
+ updateOverflowInsets()
530
584
  applyContentTransform()
531
585
  if (boundDriverId != 0.0) {
532
586
  // Host metrics only gate lifecycle readiness. Redelivery remains
@@ -602,7 +656,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
602
656
  private fun containsRoundedPoint(x: Float, y: Float): Boolean {
603
657
  // Reuse the exact path supplied to the Outline. Hit testing therefore
604
658
  // follows the rendered aperture, including continuous corners.
605
- return !clipIsEmpty && containsPathPoint(clipPath, x, y)
659
+ if (clipIsEmpty || requestedOpacity <= 0f) return false
660
+ val cx = (clipLeft + clipRight) / 2.0
661
+ val cy = (clipTop + clipBottom) / 2.0
662
+ val c = cos(requestedRotation)
663
+ val sn = sin(requestedRotation)
664
+ val localX = cx + c * (x - cx) + sn * (y - cy)
665
+ val localY = cy - sn * (x - cx) + c * (y - cy)
666
+ return containsPathPoint(clipPath, localX.toFloat(), localY.toFloat())
606
667
  }
607
668
 
608
669
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
@@ -611,12 +672,34 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
611
672
  // The aperture gates admission only. Once accepted, the stream
612
673
  // belongs to Android until its real UP or CANCEL arrives.
613
674
  clearTouchState()
614
- if (!containsRoundedPoint(event.x, event.y)) return false
675
+ if (event.x < 0 || event.y < 0 || event.x >= width || event.y >= height ||
676
+ !containsRoundedPoint(event.x, event.y)) return false
615
677
  acceptingTouchStream = true
678
+ presentationContainer.matrix.invert(inversePresentationMatrix)
679
+ touchPoint[0] = event.x
680
+ touchPoint[1] = event.y
681
+ inversePresentationMatrix.mapPoints(touchPoint)
682
+ forwardingOverflowTouchStream = touchPoint[0] < 0 || touchPoint[1] < 0 ||
683
+ touchPoint[0] >= width || touchPoint[1] >= height
616
684
  }
617
685
 
618
686
  if (!acceptingTouchStream) return false
619
- val result = super.dispatchTouchEvent(event)
687
+ // ViewGroup's native dispatch rejects children outside their layout bounds,
688
+ // even when clipChildren=false. Bypass only the internal presentation wrapper
689
+ // for an overflow stream; the clip container still dispatches to transformed
690
+ // content normally. Retain this route through the real UP/CANCEL.
691
+ val result = if (forwardingOverflowTouchStream) {
692
+ presentationContainer.matrix.invert(inversePresentationMatrix)
693
+ val localEvent = MotionEvent.obtain(event)
694
+ try {
695
+ localEvent.transform(inversePresentationMatrix)
696
+ clipContainer.dispatchTouchEvent(localEvent)
697
+ } finally {
698
+ localEvent.recycle()
699
+ }
700
+ } else {
701
+ super.dispatchTouchEvent(event)
702
+ }
620
703
  if ((action == MotionEvent.ACTION_DOWN && !result) ||
621
704
  action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP
622
705
  ) {
@@ -627,6 +710,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
627
710
 
628
711
  private fun clearTouchState() {
629
712
  acceptingTouchStream = false
713
+ forwardingOverflowTouchStream = false
630
714
  }
631
715
 
632
716
  fun setRequestedImportantForAccessibility(value: Int) {
@@ -644,14 +728,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
644
728
  fun reapplyClipPresentation() {
645
729
  val apertureVisible = apertureIntersectsHost()
646
730
  val expectedVisibility = renderVisibility(
647
- apertureVisible || shadowIntersectsHost(),
731
+ requestedOpacity > 0f && (apertureVisible || shadowIntersectsHost()),
648
732
  )
649
733
  if (visibility != expectedVisibility) {
650
734
  visibility = expectedVisibility
651
735
  }
652
736
 
653
737
  val expectedAccessibility = clipAccessibility(
654
- autonomousMotion || !apertureVisible,
738
+ autonomousMotion || !apertureVisible || requestedOpacity <= 0f,
655
739
  requestedImportantForAccessibility,
656
740
  )
657
741
  if (importantForAccessibility != expectedAccessibility) {
@@ -660,8 +744,11 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
660
744
  }
661
745
 
662
746
  private fun apertureIntersectsHost(): Boolean =
663
- !clipIsEmpty && clipRight > 0f && clipBottom > 0f &&
664
- clipLeft < width.toFloat() && clipTop < height.toFloat()
747
+ !clipIsEmpty && rotatedRectIntersectsHost(
748
+ clipLeft.toDouble(), clipTop.toDouble(), clipRight.toDouble(), clipBottom.toDouble(),
749
+ (clipLeft + clipRight) / 2.0, (clipTop + clipBottom) / 2.0,
750
+ requestedRotation, width.toDouble(), height.toDouble(),
751
+ )
665
752
 
666
753
  private fun shadowIntersectsHost(): Boolean {
667
754
  if (clipIsEmpty || !requestedShadowEnabled || requestedShadowAlpha <= 0f) {
@@ -677,9 +764,12 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
677
764
  // CSS blur is specified as a diameter-like radius. Expanding by the
678
765
  // full value is conservative and prevents culling a faint blur tail.
679
766
  val blurOutset = requestedShadowBlurRadius
680
- return pathRight + blurOutset > 0f && pathBottom + blurOutset > 0f &&
681
- pathLeft - blurOutset < width.toFloat() &&
682
- pathTop - blurOutset < height.toFloat()
767
+ return rotatedRectIntersectsHost(
768
+ (pathLeft - blurOutset).toDouble(), (pathTop - blurOutset).toDouble(),
769
+ (pathRight + blurOutset).toDouble(), (pathBottom + blurOutset).toDouble(),
770
+ (clipLeft + clipRight) / 2.0, (clipTop + clipBottom) / 2.0,
771
+ requestedRotation, width.toDouble(), height.toDouble(),
772
+ )
683
773
  }
684
774
 
685
775
  fun resetClipState() {
@@ -697,6 +787,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
697
787
  requestedContentTranslateX = 0f
698
788
  requestedContentTranslateY = 0f
699
789
  requestedContentScale = 1f
790
+ requestedRotation = 0.0
791
+ requestedOpacity = 1f
700
792
  requestedShadowEnabled = false
701
793
  requestedShadowRed = 0f
702
794
  requestedShadowGreen = 0f
@@ -37,6 +37,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
37
37
  var shadowOffsetY: Double = 0.0,
38
38
  var shadowBlurRadius: Double = 0.0,
39
39
  var shadowSpreadDistance: Double = 0.0,
40
+ var rotation: Double = 0.0,
41
+ var opacity: Double = 1.0,
40
42
  var driverId: Double = 0.0,
41
43
  // Whether any prop setter ran in the current update transaction.
42
44
  var dirty: Boolean = false,
@@ -173,6 +175,12 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
173
175
  @ReactProp(name = "initialClipBoxShadowSpreadDistance")
174
176
  override fun setInitialClipBoxShadowSpreadDistance(view: SmoothClipView, value: Double) { pending(view).shadowSpreadDistance = value }
175
177
 
178
+ @ReactProp(name = "initialRotation")
179
+ override fun setInitialRotation(view: SmoothClipView, value: Double) { pending(view).rotation = value }
180
+
181
+ @ReactProp(name = "initialOpacity", defaultDouble = 1.0)
182
+ override fun setInitialOpacity(view: SmoothClipView, value: Double) { pending(view).opacity = value }
183
+
176
184
  override fun setClipPresentation(
177
185
  view: SmoothClipView,
178
186
  x: Double,
@@ -196,6 +204,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
196
204
  shadowOffsetY: Double,
197
205
  shadowBlurRadius: Double,
198
206
  shadowSpreadDistance: Double,
207
+ rotation: Double,
208
+ opacity: Double,
199
209
  ) {
200
210
  view.commandIsAuthoritative = true
201
211
  view.setClipPresentationDip(
@@ -220,6 +230,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
220
230
  shadowOffsetY,
221
231
  shadowBlurRadius,
222
232
  shadowSpreadDistance,
233
+ rotation,
234
+ opacity,
223
235
  )
224
236
  }
225
237
 
@@ -262,6 +274,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
262
274
  initial.shadowOffsetY,
263
275
  initial.shadowBlurRadius,
264
276
  initial.shadowSpreadDistance,
277
+ initial.rotation,
278
+ initial.opacity,
265
279
  view.densityScale(),
266
280
  view.width.toDouble(),
267
281
  view.height.toDouble(),
@@ -292,6 +306,8 @@ class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
292
306
  initial.shadowOffsetY,
293
307
  initial.shadowBlurRadius,
294
308
  initial.shadowSpreadDistance,
309
+ initial.rotation,
310
+ initial.opacity,
295
311
  )
296
312
  } else {
297
313
  view.reapplyClipPresentation()
@@ -138,10 +138,10 @@ inline double cubicBezier(
138
138
 
139
139
  // --- Presentation channels ------------------------------------------------
140
140
 
141
- // The first eleven channels are geometry/content; the final eight are shadow.
141
+ // The first thirteen channels are geometry/content/appearance; the final eight are shadow.
142
142
  // Curve and shadow presence are categorical and stay outside the scalar array.
143
- constexpr std::size_t kBaseChannelCount = 11;
144
- constexpr std::size_t kChannelCount = 19;
143
+ constexpr std::size_t kBaseChannelCount = 13;
144
+ constexpr std::size_t kChannelCount = 21;
145
145
  using Channels = std::array<double, kChannelCount>;
146
146
 
147
147
  inline double resolvedRadius(double overrideValue, double shorthand) {
@@ -177,6 +177,8 @@ inline Channels toChannels(const Presentation &presentation) {
177
177
  presentation.contentTranslateX,
178
178
  presentation.contentTranslateY,
179
179
  presentation.contentScale,
180
+ presentation.rotation,
181
+ presentation.opacity,
180
182
  presentation.shadow.red,
181
183
  presentation.shadow.green,
182
184
  presentation.shadow.blue,
@@ -219,6 +221,7 @@ inline bool canonicalizePresentation(Presentation &presentation) {
219
221
  CanonicalClip clip;
220
222
  if (!SmoothClipCanonicalize(presentation.clip, clip)) return false;
221
223
  presentation.clip = SmoothClipGeometry(clip);
224
+ presentation.opacity = clamp01(presentation.opacity);
222
225
  return true;
223
226
  }
224
227
 
@@ -444,16 +447,16 @@ inline Presentation fromChannels(
444
447
  geometry.curve = curve;
445
448
  Shadow shadow{
446
449
  shadowEnabled,
447
- std::clamp(channels[11], 0.0, 1.0),
448
- std::clamp(channels[12], 0.0, 1.0),
449
450
  std::clamp(channels[13], 0.0, 1.0),
450
451
  std::clamp(channels[14], 0.0, 1.0),
451
- channels[15],
452
- channels[16],
453
- std::max(0.0, channels[17]),
454
- channels[18]};
452
+ std::clamp(channels[15], 0.0, 1.0),
453
+ std::clamp(channels[16], 0.0, 1.0),
454
+ channels[17],
455
+ channels[18],
456
+ std::max(0.0, channels[19]),
457
+ channels[20]};
455
458
  return Presentation{
456
- geometry, channels[8], channels[9], channels[10], shadow};
459
+ geometry, channels[8], channels[9], channels[10], shadow, channels[11], clamp01(channels[12])};
457
460
  }
458
461
 
459
462
  inline Presentation interpolate(
@@ -44,6 +44,8 @@ struct Presentation {
44
44
  double contentTranslateY;
45
45
  double contentScale = 1.0;
46
46
  Shadow shadow{};
47
+ double rotation = 0; // Unwrapped radians, clockwise in host coordinates.
48
+ double opacity = 1;
47
49
  };
48
50
 
49
51
  struct TimingAnimation {
@@ -156,4 +156,24 @@ inline Geometry SmoothClipGeometry(const CanonicalClip &clip) {
156
156
  return geometry;
157
157
  }
158
158
 
159
+ // Exact rectangle/host SAT; conservative for rounded corners, as before rotation.
160
+ inline bool rotatedRectIntersectsHost(double x, double y, double width, double height,
161
+ double rotation, double hostWidth, double hostHeight) {
162
+ if (width <= 0 || height <= 0 || hostWidth <= 0 || hostHeight <= 0) return false;
163
+ if (rotation == 0) return x + width > 0 && y + height > 0 && x < hostWidth && y < hostHeight;
164
+ const double c = std::cos(rotation), s = std::sin(rotation);
165
+ const double ac = std::abs(c), as = std::abs(s);
166
+ const double dx = x + width / 2 - hostWidth / 2;
167
+ const double dy = y + height / 2 - hostHeight / 2;
168
+ return std::abs(dx) < hostWidth / 2 + ac * width / 2 + as * height / 2 &&
169
+ std::abs(dy) < hostHeight / 2 + as * width / 2 + ac * height / 2 &&
170
+ std::abs(c * dx + s * dy) < width / 2 + ac * hostWidth / 2 + as * hostHeight / 2 &&
171
+ std::abs(-s * dx + c * dy) < height / 2 + as * hostWidth / 2 + ac * hostHeight / 2;
172
+ }
173
+
174
+ inline double unwrapRotation(double principal, double reference) {
175
+ constexpr double turn = 6.28318530717958647692;
176
+ return principal + std::round((reference - principal) / turn) * turn;
177
+ }
178
+
159
179
  } // namespace smoothclip
@@ -11,6 +11,12 @@ namespace smoothclip {
11
11
  // the four radius channels contributes one quarter of the uniform-radius weight.
12
12
  // Both platforms record raw canonical geometry, including off-host coordinates.
13
13
  // The projection only needs both samples and the target in the same space.
14
+ // Rotation, opacity, and shadow channels are deliberately absent: their units
15
+ // must not enter this geometry/translation projection. Appearance-only motion
16
+ // cannot supply inherited velocity. The resulting normalized scalar can still
17
+ // seed every changing animation channel, including rotation and opacity.
18
+ // This inheritance path is internal to standalone native animations; public
19
+ // controllers/groups use an explicit progress velocity, defaulting to zero.
14
20
  // Shared by ios/SmoothClipView.mm and android/.../SmoothClipRegistry.cpp; a
15
21
  // behavior change here changes both platforms.
16
22
  struct VelocitySampleHistory {
@@ -10,7 +10,7 @@
10
10
  namespace facebook::react {
11
11
  namespace {
12
12
 
13
- constexpr size_t kPresentationStride = 21;
13
+ constexpr size_t kPresentationStride = 23;
14
14
  constexpr size_t kSnapshotStride = kPresentationStride + 1;
15
15
  constexpr size_t kMotionEntryStride = kPresentationStride * 2 + 2;
16
16
 
@@ -65,7 +65,9 @@ smoothclip::Presentation makePresentation(
65
65
  double shadowOffsetX = 0,
66
66
  double shadowOffsetY = 0,
67
67
  double shadowBlurRadius = 0,
68
- double shadowSpreadDistance = 0) {
68
+ double shadowSpreadDistance = 0,
69
+ double rotation = 0,
70
+ double opacity = 1) {
69
71
  const bool uniform = topLeftRadius == topRightRadius &&
70
72
  topLeftRadius == bottomRightRadius &&
71
73
  topLeftRadius == bottomLeftRadius;
@@ -82,7 +84,8 @@ smoothclip::Presentation makePresentation(
82
84
  contentTranslateY,
83
85
  contentScale,
84
86
  {shadowEnabled, shadowRed, shadowGreen, shadowBlue, shadowAlpha,
85
- shadowOffsetX, shadowOffsetY, shadowBlurRadius, shadowSpreadDistance}};
87
+ shadowOffsetX, shadowOffsetY, shadowBlurRadius, shadowSpreadDistance},
88
+ rotation, opacity};
86
89
  }
87
90
 
88
91
  bool finitePresentation(const smoothclip::Presentation &presentation) {
@@ -97,6 +100,7 @@ bool finitePresentation(const smoothclip::Presentation &presentation) {
97
100
  std::isfinite(presentation.contentTranslateY) &&
98
101
  std::isfinite(presentation.contentScale) &&
99
102
  presentation.contentScale > 0 &&
103
+ std::isfinite(presentation.rotation) && std::isfinite(presentation.opacity) &&
100
104
  std::isfinite(presentation.shadow.red) &&
101
105
  std::isfinite(presentation.shadow.green) &&
102
106
  std::isfinite(presentation.shadow.blue) &&
@@ -141,7 +145,7 @@ bool presentationAt(
141
145
  values[11],
142
146
  values[12] == 1,
143
147
  values[13], values[14], values[15], values[16],
144
- values[17], values[18], values[19], values[20]);
148
+ values[17], values[18], values[19], values[20], values[21], values[22]);
145
149
  return finitePresentation(result);
146
150
  }
147
151
 
@@ -181,6 +185,8 @@ void writePresentation(
181
185
  result.setValueAtIndex(runtime, offset + 18, presentation.shadow.offsetY);
182
186
  result.setValueAtIndex(runtime, offset + 19, presentation.shadow.blurRadius);
183
187
  result.setValueAtIndex(runtime, offset + 20, presentation.shadow.spreadDistance);
188
+ result.setValueAtIndex(runtime, offset + 21, presentation.rotation);
189
+ result.setValueAtIndex(runtime, offset + 22, presentation.opacity);
184
190
  }
185
191
 
186
192
  jsi::Array snapshotArray(
@@ -226,6 +232,8 @@ std::vector<double> snapshotValues(
226
232
  result.push_back(presentation.shadow.offsetY);
227
233
  result.push_back(presentation.shadow.blurRadius);
228
234
  result.push_back(presentation.shadow.spreadDistance);
235
+ result.push_back(presentation.rotation);
236
+ result.push_back(presentation.opacity);
229
237
  }
230
238
  return result;
231
239
  }
@@ -504,13 +512,16 @@ void SmoothClipTurboModule::setClipFrameScalars(
504
512
  double shadowOffsetX,
505
513
  double shadowOffsetY,
506
514
  double shadowBlurRadius,
507
- double shadowSpreadDistance) {
515
+ double shadowSpreadDistance,
516
+ double rotation,
517
+ double opacity) {
508
518
  const smoothclip::Presentation presentation = makePresentation(
509
519
  x, y, width, height, topLeftRadius, topRightRadius,
510
520
  bottomRightRadius, bottomLeftRadius, curveCode,
511
521
  contentTranslateX, contentTranslateY, contentScale,
512
522
  shadowEnabled, shadowRed, shadowGreen, shadowBlue, shadowAlpha,
513
- shadowOffsetX, shadowOffsetY, shadowBlurRadius, shadowSpreadDistance);
523
+ shadowOffsetX, shadowOffsetY, shadowBlurRadius, shadowSpreadDistance,
524
+ rotation, opacity);
514
525
  if (!validDriverId(driverId) || !finitePresentation(presentation)) return;
515
526
  smoothclip::setPresentation(
516
527
  static_cast<uint64_t>(driverId), presentation, true, false, true);
@@ -160,7 +160,9 @@ class SmoothClipTurboModule final
160
160
  double shadowOffsetX,
161
161
  double shadowOffsetY,
162
162
  double shadowBlurRadius,
163
- double shadowSpreadDistance);
163
+ double shadowSpreadDistance,
164
+ double rotation,
165
+ double opacity);
164
166
  jsi::Array beginInteraction(jsi::Runtime &runtime, double driverId);
165
167
  jsi::Array snapshotCurrent(jsi::Runtime &runtime, double driverId);
166
168