react-native-smooth-clip-view 0.3.0 → 0.4.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 (199) hide show
  1. package/README.md +135 -386
  2. package/android/build.gradle +4 -7
  3. package/android/proguard-rules.pro +2 -2
  4. package/android/src/main/cpp/SmoothClipAndroid.h +9 -12
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +300 -704
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +348 -594
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +36 -227
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +16 -19
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +36 -294
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +369 -486
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +75 -77
  12. package/cpp/SmoothClipAnimationCurve.h +237 -346
  13. package/cpp/SmoothClipRegistry.h +24 -52
  14. package/cpp/SmoothClipSharedGeometry.h +64 -94
  15. package/cpp/SmoothClipVelocityTracker.h +5 -5
  16. package/ios/SmoothClipGeometry.h +23 -34
  17. package/ios/SmoothClipRegistry.mm +206 -711
  18. package/ios/SmoothClipTurboModule.cpp +217 -715
  19. package/ios/SmoothClipTurboModule.h +54 -297
  20. package/ios/SmoothClipView.mm +599 -505
  21. package/ios/SmoothClipViewRegistry.h +2 -7
  22. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  23. package/lib/module/SmoothClipView.ios.js +84 -17
  24. package/lib/module/SmoothClipView.ios.js.map +1 -1
  25. package/lib/module/SmoothClipView.js +1 -1
  26. package/lib/module/SmoothClipView.js.map +1 -1
  27. package/lib/module/SmoothClipViewNativeComponent.ts +20 -26
  28. package/lib/module/capabilities.native.js +1 -19
  29. package/lib/module/capabilities.native.js.map +1 -1
  30. package/lib/module/capabilityTypes.js +0 -9
  31. package/lib/module/capabilityTypes.js.map +1 -1
  32. package/lib/module/controllerInternals.js +28 -0
  33. package/lib/module/controllerInternals.js.map +1 -0
  34. package/lib/module/controllerLifecycle.js +9 -0
  35. package/lib/module/controllerLifecycle.js.map +1 -0
  36. package/lib/module/controllerTypes.js +4 -0
  37. package/lib/module/controllerTypes.js.map +1 -0
  38. package/lib/module/controllers.android.js +4 -0
  39. package/lib/module/controllers.android.js.map +1 -0
  40. package/lib/module/controllers.ios.js +4 -0
  41. package/lib/module/controllers.ios.js.map +1 -0
  42. package/lib/module/controllers.js +4 -0
  43. package/lib/module/controllers.js.map +1 -0
  44. package/lib/module/controllers.native.js +89 -0
  45. package/lib/module/controllers.native.js.map +1 -0
  46. package/lib/module/geometry.js +71 -51
  47. package/lib/module/geometry.js.map +1 -1
  48. package/lib/module/groupTypes.js +4 -0
  49. package/lib/module/{groupDriverTypes.js.map → groupTypes.js.map} +1 -1
  50. package/lib/module/groups.android.js +4 -0
  51. package/lib/module/groups.android.js.map +1 -0
  52. package/lib/module/groups.ios.js +4 -0
  53. package/lib/module/groups.ios.js.map +1 -0
  54. package/lib/module/groups.js +4 -0
  55. package/lib/module/groups.js.map +1 -0
  56. package/lib/module/groups.native.js +399 -0
  57. package/lib/module/groups.native.js.map +1 -0
  58. package/lib/module/ids.js +8 -0
  59. package/lib/module/ids.js.map +1 -0
  60. package/lib/module/index.js +3 -3
  61. package/lib/module/index.js.map +1 -1
  62. package/lib/module/nativeCompletion.js +37 -16
  63. package/lib/module/nativeCompletion.js.map +1 -1
  64. package/lib/module/presentationCodec.js +81 -0
  65. package/lib/module/presentationCodec.js.map +1 -0
  66. package/lib/typescript/src/NativeSmoothClipModule.d.ts +14 -23
  67. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  68. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  69. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  70. package/lib/typescript/src/SmoothClipView.ios.d.ts +18 -14
  71. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  72. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +10 -5
  73. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  74. package/lib/typescript/src/capabilities.native.d.ts +1 -1
  75. package/lib/typescript/src/capabilities.native.d.ts.map +1 -1
  76. package/lib/typescript/src/capabilityTypes.d.ts +0 -6
  77. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -1
  78. package/lib/typescript/src/controllerInternals.d.ts +14 -0
  79. package/lib/typescript/src/controllerInternals.d.ts.map +1 -0
  80. package/lib/typescript/src/controllerLifecycle.d.ts +3 -0
  81. package/lib/typescript/src/controllerLifecycle.d.ts.map +1 -0
  82. package/lib/typescript/src/controllerTypes.d.ts +62 -0
  83. package/lib/typescript/src/controllerTypes.d.ts.map +1 -0
  84. package/lib/typescript/src/controllers.android.d.ts +2 -0
  85. package/lib/typescript/src/controllers.android.d.ts.map +1 -0
  86. package/lib/typescript/src/controllers.d.ts +2 -0
  87. package/lib/typescript/src/controllers.d.ts.map +1 -0
  88. package/lib/typescript/src/controllers.ios.d.ts +2 -0
  89. package/lib/typescript/src/controllers.ios.d.ts.map +1 -0
  90. package/lib/typescript/src/controllers.native.d.ts +3 -0
  91. package/lib/typescript/src/controllers.native.d.ts.map +1 -0
  92. package/lib/typescript/src/geometry.d.ts +22 -12
  93. package/lib/typescript/src/geometry.d.ts.map +1 -1
  94. package/lib/typescript/src/groupTypes.d.ts +31 -0
  95. package/lib/typescript/src/groupTypes.d.ts.map +1 -0
  96. package/lib/typescript/src/groups.android.d.ts +2 -0
  97. package/lib/typescript/src/groups.android.d.ts.map +1 -0
  98. package/lib/typescript/src/groups.d.ts +2 -0
  99. package/lib/typescript/src/groups.d.ts.map +1 -0
  100. package/lib/typescript/src/groups.ios.d.ts +2 -0
  101. package/lib/typescript/src/groups.ios.d.ts.map +1 -0
  102. package/lib/typescript/src/groups.native.d.ts +3 -0
  103. package/lib/typescript/src/groups.native.d.ts.map +1 -0
  104. package/lib/typescript/src/ids.d.ts +2 -0
  105. package/lib/typescript/src/ids.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +6 -6
  107. package/lib/typescript/src/index.d.ts.map +1 -1
  108. package/lib/typescript/src/nativeCompletion.d.ts +16 -6
  109. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -1
  110. package/lib/typescript/src/presentationCodec.d.ts +10 -0
  111. package/lib/typescript/src/presentationCodec.d.ts.map +1 -0
  112. package/package.json +8 -3
  113. package/src/NativeSmoothClipModule.ts +41 -292
  114. package/src/SmoothClipView.ios.tsx +130 -23
  115. package/src/SmoothClipView.tsx +5 -1
  116. package/src/SmoothClipViewNativeComponent.ts +20 -26
  117. package/src/capabilities.native.ts +2 -52
  118. package/src/capabilityTypes.ts +0 -14
  119. package/src/controllerInternals.ts +52 -0
  120. package/src/controllerLifecycle.ts +6 -0
  121. package/src/controllerTypes.ts +87 -0
  122. package/src/controllers.android.ts +1 -0
  123. package/src/controllers.ios.ts +1 -0
  124. package/src/controllers.native.ts +104 -0
  125. package/src/controllers.ts +1 -0
  126. package/src/geometry.ts +118 -74
  127. package/src/groupTypes.ts +53 -0
  128. package/src/groups.android.ts +1 -0
  129. package/src/groups.ios.ts +1 -0
  130. package/src/groups.native.ts +604 -0
  131. package/src/groups.ts +1 -0
  132. package/src/ids.ts +6 -0
  133. package/src/index.ts +20 -28
  134. package/src/nativeCompletion.ts +67 -32
  135. package/src/presentationCodec.ts +134 -0
  136. package/lib/module/driverState.js +0 -82
  137. package/lib/module/driverState.js.map +0 -1
  138. package/lib/module/driverTypes.js +0 -4
  139. package/lib/module/driverTypes.js.map +0 -1
  140. package/lib/module/drivers.android.js +0 -6
  141. package/lib/module/drivers.android.js.map +0 -1
  142. package/lib/module/drivers.ios.js +0 -6
  143. package/lib/module/drivers.ios.js.map +0 -1
  144. package/lib/module/drivers.js +0 -4
  145. package/lib/module/drivers.js.map +0 -1
  146. package/lib/module/drivers.native.js +0 -756
  147. package/lib/module/drivers.native.js.map +0 -1
  148. package/lib/module/groupDriverTypes.js +0 -4
  149. package/lib/module/groupDrivers.android.js +0 -4
  150. package/lib/module/groupDrivers.android.js.map +0 -1
  151. package/lib/module/groupDrivers.ios.js +0 -4
  152. package/lib/module/groupDrivers.ios.js.map +0 -1
  153. package/lib/module/groupDrivers.js +0 -4
  154. package/lib/module/groupDrivers.js.map +0 -1
  155. package/lib/module/groupDrivers.native.js +0 -799
  156. package/lib/module/groupDrivers.native.js.map +0 -1
  157. package/lib/module/presentationProtocol.js +0 -15
  158. package/lib/module/presentationProtocol.js.map +0 -1
  159. package/lib/module/reactRequests.js +0 -56
  160. package/lib/module/reactRequests.js.map +0 -1
  161. package/lib/typescript/src/driverState.d.ts +0 -26
  162. package/lib/typescript/src/driverState.d.ts.map +0 -1
  163. package/lib/typescript/src/driverTypes.d.ts +0 -121
  164. package/lib/typescript/src/driverTypes.d.ts.map +0 -1
  165. package/lib/typescript/src/drivers.android.d.ts +0 -3
  166. package/lib/typescript/src/drivers.android.d.ts.map +0 -1
  167. package/lib/typescript/src/drivers.d.ts +0 -3
  168. package/lib/typescript/src/drivers.d.ts.map +0 -1
  169. package/lib/typescript/src/drivers.ios.d.ts +0 -3
  170. package/lib/typescript/src/drivers.ios.d.ts.map +0 -1
  171. package/lib/typescript/src/drivers.native.d.ts +0 -5
  172. package/lib/typescript/src/drivers.native.d.ts.map +0 -1
  173. package/lib/typescript/src/groupDriverTypes.d.ts +0 -66
  174. package/lib/typescript/src/groupDriverTypes.d.ts.map +0 -1
  175. package/lib/typescript/src/groupDrivers.android.d.ts +0 -2
  176. package/lib/typescript/src/groupDrivers.android.d.ts.map +0 -1
  177. package/lib/typescript/src/groupDrivers.d.ts +0 -2
  178. package/lib/typescript/src/groupDrivers.d.ts.map +0 -1
  179. package/lib/typescript/src/groupDrivers.ios.d.ts +0 -2
  180. package/lib/typescript/src/groupDrivers.ios.d.ts.map +0 -1
  181. package/lib/typescript/src/groupDrivers.native.d.ts +0 -4
  182. package/lib/typescript/src/groupDrivers.native.d.ts.map +0 -1
  183. package/lib/typescript/src/presentationProtocol.d.ts +0 -4
  184. package/lib/typescript/src/presentationProtocol.d.ts.map +0 -1
  185. package/lib/typescript/src/reactRequests.d.ts +0 -9
  186. package/lib/typescript/src/reactRequests.d.ts.map +0 -1
  187. package/src/driverState.ts +0 -118
  188. package/src/driverTypes.ts +0 -171
  189. package/src/drivers.android.ts +0 -8
  190. package/src/drivers.ios.ts +0 -8
  191. package/src/drivers.native.ts +0 -1599
  192. package/src/drivers.ts +0 -6
  193. package/src/groupDriverTypes.ts +0 -117
  194. package/src/groupDrivers.android.ts +0 -1
  195. package/src/groupDrivers.ios.ts +0 -1
  196. package/src/groupDrivers.native.ts +0 -1189
  197. package/src/groupDrivers.ts +0 -1
  198. package/src/presentationProtocol.ts +0 -28
  199. package/src/reactRequests.ts +0 -76
@@ -1,8 +1,14 @@
1
1
  package com.smoothclipview
2
2
 
3
3
  import android.content.res.Configuration
4
+ import android.graphics.BlurMaskFilter
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
4
7
  import android.graphics.Outline
8
+ import android.graphics.Paint
5
9
  import android.graphics.Path
10
+ import android.graphics.Region
11
+ import android.os.Build
6
12
  import android.os.Trace
7
13
  import android.view.MotionEvent
8
14
  import android.view.View
@@ -11,14 +17,35 @@ import com.facebook.proguard.annotations.DoNotStrip
11
17
  import com.facebook.react.uimanager.PixelUtil
12
18
  import com.facebook.react.uimanager.ThemedReactContext
13
19
  import com.facebook.react.views.view.ReactViewGroup
20
+ import kotlin.math.roundToInt
21
+ import kotlin.math.abs
22
+ import kotlin.math.pow
14
23
 
15
24
  class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
25
+ private val clipPath = Path()
26
+ private val supportsPathOutlineClipping =
27
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
28
+ private val clipContainer: ReactViewGroup =
29
+ if (supportsPathOutlineClipping) {
30
+ ReactViewGroup(context)
31
+ } else {
32
+ object : ReactViewGroup(context) {
33
+ override fun dispatchDraw(canvas: Canvas) {
34
+ val saveCount = canvas.save()
35
+ canvas.clipPath(clipPath)
36
+ try {
37
+ super.dispatchDraw(canvas)
38
+ } finally {
39
+ canvas.restoreToCount(saveCount)
40
+ }
41
+ }
42
+ }
43
+ }
16
44
  internal val contentContainer = ReactViewGroup(context)
17
45
  private var requestedX = 0f
18
46
  private var requestedY = 0f
19
47
  private var requestedWidth = 0f
20
48
  private var requestedHeight = 0f
21
- private var requestedRadius = 0f
22
49
  private var requestedTopLeftRadius = 0f
23
50
  private var requestedTopRightRadius = 0f
24
51
  private var requestedBottomRightRadius = 0f
@@ -27,43 +54,35 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
27
54
  private var requestedContentTranslateX = 0f
28
55
  private var requestedContentTranslateY = 0f
29
56
  private var requestedContentScale = 1f
30
- private var requestedUsesV2Geometry = false
57
+ private var requestedShadowEnabled = false
58
+ private var requestedShadowRed = 0f
59
+ private var requestedShadowGreen = 0f
60
+ private var requestedShadowBlue = 0f
61
+ private var requestedShadowAlpha = 1f
62
+ private var requestedShadowOffsetX = 0f
63
+ private var requestedShadowOffsetY = 0f
64
+ private var requestedShadowBlurRadius = 0f
65
+ private var requestedShadowSpreadDistance = 0f
31
66
  private var clipLeft = 0f
32
67
  private var clipTop = 0f
33
68
  private var clipRight = 0f
34
69
  private var clipBottom = 0f
35
- private var clipRadius = 0f
36
70
  private var clipTopLeftRadius = 0f
37
71
  private var clipTopRightRadius = 0f
38
72
  private var clipBottomRightRadius = 0f
39
73
  private var clipBottomLeftRadius = 0f
40
74
  private var clipCurveCode = CLIP_CURVE_CIRCULAR
41
- private val clipPath = Path()
42
- private var outlineUsesPath = false
43
- private var outlineUsesFloatRoundRect = false
44
- // Rounded edges actually emitted to the outline provider. Sub-pixel changes
45
- // that round to the same integers must not invalidate the outline.
46
- private var outlineLeft = 0
47
- private var outlineTop = 0
48
- private var outlineRight = 0
49
- private var outlineBottom = 0
75
+ private var boxShadowPath: Path? = null
76
+ private var boxShadowPaint: Paint? = null
50
77
  private var clipIsEmpty = true
51
- // Sub-pixel remainder of the clip origin, carried on this view's own
52
- // translation and subtracted back out of the content container.
53
- private var clipResidualX = 0f
54
- private var clipResidualY = 0f
55
- // The consumer's `transform` translation, kept apart from the residual so
56
- // the two compose instead of clobbering each other.
57
- private var userTranslationX = 0f
58
- private var userTranslationY = 0f
59
78
  private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
79
+ private var autonomousMotion = false
60
80
  private var acceptingTouchStream = false
61
- private var retainedTouchEvent: MotionEvent? = null
62
81
 
63
82
  /** Driver this view is registered with in the native registry (0 = none). */
64
83
  internal var boundDriverId: Double = 0.0
65
84
 
66
- /** Set once a legacy command arrives; later initialClip* props are ignored. */
85
+ /** Set once a command arrives; later initial presentation props are ignored. */
67
86
  internal var commandIsAuthoritative = false
68
87
 
69
88
  private val clipOutlineProvider = object : ViewOutlineProvider() {
@@ -73,93 +92,32 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
73
92
  return
74
93
  }
75
94
 
76
- if (outlineUsesPath) {
77
- outline.setPath(clipPath)
78
- } else if (outlineUsesFloatRoundRect) {
79
- // Android's public Outline.setRoundRect overload accepts only
80
- // integer edges. Path.addRoundRect is the platform float
81
- // round-rect primitive; Outline.setPath preserves those edges
82
- // without falling back to V1's integer/residual placement.
95
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
96
+ // Outline.setRoundRect only accepts integer edges. The raw path
97
+ // keeps sub-pixel coordinates and off-host geometry intact.
83
98
  outline.setPath(clipPath)
84
99
  } else {
85
- outline.setRoundRect(
86
- outlineLeft,
87
- outlineTop,
88
- outlineRight,
89
- outlineBottom,
90
- clipRadius,
91
- )
100
+ outline.setEmpty()
92
101
  }
93
102
  }
94
103
  }
95
104
 
96
105
  init {
97
- super.addView(contentContainer)
98
- outlineProvider = clipOutlineProvider
99
- clipToOutline = true
106
+ clipContainer.addView(contentContainer)
107
+ super.addView(clipContainer)
108
+ if (supportsPathOutlineClipping) {
109
+ clipContainer.outlineProvider = clipOutlineProvider
110
+ clipContainer.clipToOutline = true
111
+ }
112
+ clipToOutline = false
113
+ setWillNotDraw(false)
100
114
  visibility = INVISIBLE
101
115
  importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
102
116
  }
103
117
 
104
- fun setClipGeometryDip(
105
- x: Double,
106
- y: Double,
107
- width: Double,
108
- height: Double,
109
- radius: Double,
110
- ) = setClipPresentationDip(x, y, width, height, radius, 0.0, 0.0)
111
-
118
+ /** DIP fallback used until a registered view has pushed host metrics. */
112
119
  @DoNotStrip
113
120
  fun setClipPresentationDip(
114
- x: Double,
115
- y: Double,
116
- width: Double,
117
- height: Double,
118
- radius: Double,
119
- contentTranslateX: Double,
120
- contentTranslateY: Double,
121
- ) {
122
- if (!x.isFinite() || !y.isFinite() || !width.isFinite() ||
123
- !height.isFinite() || !radius.isFinite() ||
124
- !contentTranslateX.isFinite() || !contentTranslateY.isFinite()
125
- ) {
126
- return
127
- }
128
-
129
- val nextX = PixelUtil.toPixelFromDIP(x).toFloat()
130
- val nextY = PixelUtil.toPixelFromDIP(y).toFloat()
131
- val nextWidth = PixelUtil.toPixelFromDIP(width).toFloat()
132
- val nextHeight = PixelUtil.toPixelFromDIP(height).toFloat()
133
- val nextRadius = PixelUtil.toPixelFromDIP(radius).toFloat()
134
- val nextContentTranslateX = PixelUtil.toPixelFromDIP(contentTranslateX).toFloat()
135
- val nextContentTranslateY = PixelUtil.toPixelFromDIP(contentTranslateY).toFloat()
136
- if (!nextX.isFinite() || !nextY.isFinite() || !nextWidth.isFinite() ||
137
- !nextHeight.isFinite() || !nextRadius.isFinite() ||
138
- !nextContentTranslateX.isFinite() || !nextContentTranslateY.isFinite()
139
- ) {
140
- return
141
- }
142
-
143
- requestedX = nextX
144
- requestedY = nextY
145
- requestedWidth = nextWidth
146
- requestedHeight = nextHeight
147
- requestedRadius = nextRadius
148
- requestedTopLeftRadius = nextRadius
149
- requestedTopRightRadius = nextRadius
150
- requestedBottomRightRadius = nextRadius
151
- requestedBottomLeftRadius = nextRadius
152
- requestedCurveCode = CLIP_CURVE_CIRCULAR
153
- requestedContentTranslateX = nextContentTranslateX
154
- requestedContentTranslateY = nextContentTranslateY
155
- requestedContentScale = 1f
156
- requestedUsesV2Geometry = false
157
- applyRequestedGeometry()
158
- }
159
-
160
- /** V2 DIP fallback used until a registered view has pushed host metrics. */
161
- @DoNotStrip
162
- fun setClipPresentationV2Dip(
163
121
  x: Double,
164
122
  y: Double,
165
123
  width: Double,
@@ -172,13 +130,29 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
172
130
  contentTranslateX: Double,
173
131
  contentTranslateY: Double,
174
132
  contentScale: Double,
133
+ shadowEnabled: Boolean = false,
134
+ shadowRed: Double = 0.0,
135
+ shadowGreen: Double = 0.0,
136
+ shadowBlue: Double = 0.0,
137
+ shadowAlpha: Double = 1.0,
138
+ shadowOffsetX: Double = 0.0,
139
+ shadowOffsetY: Double = 0.0,
140
+ shadowBlurRadius: Double = 0.0,
141
+ shadowSpreadDistance: Double = 0.0,
175
142
  ) {
176
143
  if (!x.isFinite() || !y.isFinite() || !width.isFinite() ||
177
144
  !height.isFinite() || !topLeftRadius.isFinite() ||
178
145
  !topRightRadius.isFinite() || !bottomRightRadius.isFinite() ||
179
146
  !bottomLeftRadius.isFinite() || !contentTranslateX.isFinite() ||
180
147
  !contentTranslateY.isFinite() || !contentScale.isFinite() ||
181
- contentScale <= 0.0 ||
148
+ !shadowRed.isFinite() || !shadowGreen.isFinite() ||
149
+ !shadowBlue.isFinite() || !shadowAlpha.isFinite() ||
150
+ !shadowOffsetX.isFinite() ||
151
+ !shadowOffsetY.isFinite() || !shadowBlurRadius.isFinite() ||
152
+ !shadowSpreadDistance.isFinite() || contentScale <= 0.0 ||
153
+ shadowRed !in 0.0..1.0 || shadowGreen !in 0.0..1.0 ||
154
+ shadowBlue !in 0.0..1.0 || shadowAlpha !in 0.0..1.0 ||
155
+ shadowBlurRadius < 0.0 ||
182
156
  (curveCode != CLIP_CURVE_CIRCULAR && curveCode != CLIP_CURVE_CONTINUOUS)
183
157
  ) {
184
158
  return
@@ -195,12 +169,19 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
195
169
  val nextContentTranslateX = PixelUtil.toPixelFromDIP(contentTranslateX).toFloat()
196
170
  val nextContentTranslateY = PixelUtil.toPixelFromDIP(contentTranslateY).toFloat()
197
171
  val nextContentScale = contentScale.toFloat()
172
+ val nextShadowOffsetX = PixelUtil.toPixelFromDIP(shadowOffsetX).toFloat()
173
+ val nextShadowOffsetY = PixelUtil.toPixelFromDIP(shadowOffsetY).toFloat()
174
+ val nextShadowBlurRadius = PixelUtil.toPixelFromDIP(shadowBlurRadius).toFloat()
175
+ val nextShadowSpreadDistance =
176
+ PixelUtil.toPixelFromDIP(shadowSpreadDistance).toFloat()
198
177
  if (!nextX.isFinite() || !nextY.isFinite() || !nextWidth.isFinite() ||
199
178
  !nextHeight.isFinite() || !nextTopLeftRadius.isFinite() ||
200
179
  !nextTopRightRadius.isFinite() || !nextBottomRightRadius.isFinite() ||
201
180
  !nextBottomLeftRadius.isFinite() || !nextContentTranslateX.isFinite() ||
202
181
  !nextContentTranslateY.isFinite() || !nextContentScale.isFinite() ||
203
- nextContentScale <= 0f
182
+ !nextShadowOffsetX.isFinite() || !nextShadowOffsetY.isFinite() ||
183
+ !nextShadowBlurRadius.isFinite() ||
184
+ !nextShadowSpreadDistance.isFinite() || nextContentScale <= 0f
204
185
  ) {
205
186
  return
206
187
  }
@@ -209,7 +190,6 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
209
190
  requestedY = nextY
210
191
  requestedWidth = nextWidth
211
192
  requestedHeight = nextHeight
212
- requestedRadius = 0f
213
193
  requestedTopLeftRadius = nextTopLeftRadius
214
194
  requestedTopRightRadius = nextTopRightRadius
215
195
  requestedBottomRightRadius = nextBottomRightRadius
@@ -218,47 +198,23 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
218
198
  requestedContentTranslateX = nextContentTranslateX
219
199
  requestedContentTranslateY = nextContentTranslateY
220
200
  requestedContentScale = nextContentScale
221
- requestedUsesV2Geometry = true
201
+ storeShadow(
202
+ shadowEnabled,
203
+ shadowRed.toFloat(),
204
+ shadowGreen.toFloat(),
205
+ shadowBlue.toFloat(),
206
+ shadowAlpha.toFloat(),
207
+ nextShadowOffsetX,
208
+ nextShadowOffsetY,
209
+ nextShadowBlurRadius,
210
+ nextShadowSpreadDistance,
211
+ )
222
212
  applyRequestedGeometry()
223
213
  }
224
214
 
225
- /**
226
- * Driver hot path: called from the C++ registry with final pixel-space,
227
- * pre-normalized values (see deliverToView in SmoothClipRegistry.cpp).
228
- * No validation, scaling or normalization happens here.
229
- */
215
+ /** Driver hot path. Geometry is canonical, raw, and host-independent. */
230
216
  @DoNotStrip
231
217
  fun setClipPresentationPx(
232
- left: Float,
233
- top: Float,
234
- right: Float,
235
- bottom: Float,
236
- radius: Float,
237
- contentTranslateX: Float,
238
- contentTranslateY: Float,
239
- ) {
240
- // Debug-only Perfetto slice for the Kotlin share of the C++
241
- // SmoothClip.setPresentation section (see SmoothClipTrace.h).
242
- if (BuildConfig.DEBUG) Trace.beginSection("SmoothClip.applyPx")
243
- try {
244
- // Stored only; applyNormalizedClipPx computes the clip residual and
245
- // applyClipPlacement writes both translations together, because the
246
- // content's final offset depends on the residual as well.
247
- requestedContentTranslateX = contentTranslateX
248
- requestedContentTranslateY = contentTranslateY
249
- requestedContentScale = 1f
250
- applyNormalizedClipPx(left, top, right, bottom, radius)
251
- } finally {
252
- if (BuildConfig.DEBUG) Trace.endSection()
253
- }
254
- }
255
-
256
- /**
257
- * V2 driver hot path. Geometry is already intersected with the host and
258
- * its four radii have already had the CSS overlap factor applied in C++.
259
- */
260
- @DoNotStrip
261
- fun setClipPresentationV2Px(
262
218
  left: Float,
263
219
  top: Float,
264
220
  right: Float,
@@ -271,24 +227,51 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
271
227
  contentTranslateX: Float,
272
228
  contentTranslateY: Float,
273
229
  contentScale: Float,
230
+ shadowEnabled: Boolean = false,
231
+ shadowRed: Float = 0f,
232
+ shadowGreen: Float = 0f,
233
+ shadowBlue: Float = 0f,
234
+ shadowAlpha: Float = 1f,
235
+ shadowOffsetX: Float = 0f,
236
+ shadowOffsetY: Float = 0f,
237
+ shadowBlurRadius: Float = 0f,
238
+ shadowSpreadDistance: Float = 0f,
274
239
  ) {
275
240
  if (!left.isFinite() || !top.isFinite() || !right.isFinite() ||
276
241
  !bottom.isFinite() || !topLeftRadius.isFinite() ||
277
242
  !topRightRadius.isFinite() || !bottomRightRadius.isFinite() ||
278
243
  !bottomLeftRadius.isFinite() || !contentTranslateX.isFinite() ||
279
244
  !contentTranslateY.isFinite() || !contentScale.isFinite() ||
280
- contentScale <= 0f ||
245
+ !shadowRed.isFinite() || !shadowGreen.isFinite() ||
246
+ !shadowBlue.isFinite() || !shadowAlpha.isFinite() ||
247
+ !shadowOffsetX.isFinite() ||
248
+ !shadowOffsetY.isFinite() || !shadowBlurRadius.isFinite() ||
249
+ !shadowSpreadDistance.isFinite() || contentScale <= 0f ||
250
+ shadowRed !in 0f..1f || shadowGreen !in 0f..1f ||
251
+ shadowBlue !in 0f..1f || shadowAlpha !in 0f..1f ||
252
+ shadowBlurRadius < 0f ||
281
253
  (curveCode != CLIP_CURVE_CIRCULAR && curveCode != CLIP_CURVE_CONTINUOUS)
282
254
  ) {
283
255
  return
284
256
  }
285
257
 
286
- if (BuildConfig.DEBUG) Trace.beginSection("SmoothClip.applyV2Px")
258
+ if (BuildConfig.DEBUG) Trace.beginSection("SmoothClip.applyPresentationPx")
287
259
  try {
288
260
  requestedContentTranslateX = contentTranslateX
289
261
  requestedContentTranslateY = contentTranslateY
290
262
  requestedContentScale = contentScale
291
- applyNormalizedClipV2Px(
263
+ storeShadow(
264
+ shadowEnabled,
265
+ shadowRed,
266
+ shadowGreen,
267
+ shadowBlue,
268
+ shadowAlpha,
269
+ shadowOffsetX,
270
+ shadowOffsetY,
271
+ shadowBlurRadius,
272
+ shadowSpreadDistance,
273
+ )
274
+ applyCanonicalClipPx(
292
275
  left,
293
276
  top,
294
277
  right,
@@ -305,135 +288,155 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
305
288
  }
306
289
 
307
290
  private fun applyRequestedGeometry() {
308
- if (requestedUsesV2Geometry) {
309
- normalizeClipGeometryV2Px(
310
- requestedX,
311
- requestedY,
312
- requestedWidth,
313
- requestedHeight,
314
- requestedTopLeftRadius,
315
- requestedTopRightRadius,
316
- requestedBottomRightRadius,
317
- requestedBottomLeftRadius,
318
- requestedCurveCode,
319
- width.toFloat(),
320
- height.toFloat(),
321
- ) { left, top, right, bottom, topLeft, topRight, bottomRight, bottomLeft, curve ->
322
- applyNormalizedClipV2Px(
323
- left,
324
- top,
325
- right,
326
- bottom,
327
- topLeft,
328
- topRight,
329
- bottomRight,
330
- bottomLeft,
331
- curve,
332
- )
333
- }
334
- } else {
335
- normalizeClipGeometryPx(
336
- requestedX,
337
- requestedY,
338
- requestedWidth,
339
- requestedHeight,
340
- requestedRadius,
341
- width.toFloat(),
342
- height.toFloat(),
343
- ) { left, top, right, bottom, radius ->
344
- applyNormalizedClipPx(left, top, right, bottom, radius)
345
- }
291
+ canonicalizeClipGeometryPx(
292
+ requestedX,
293
+ requestedY,
294
+ requestedWidth,
295
+ requestedHeight,
296
+ requestedTopLeftRadius,
297
+ requestedTopRightRadius,
298
+ requestedBottomRightRadius,
299
+ requestedBottomLeftRadius,
300
+ requestedCurveCode,
301
+ ) { left, top, right, bottom, topLeft, topRight, bottomRight, bottomLeft, curve ->
302
+ applyCanonicalClipPx(
303
+ left,
304
+ top,
305
+ right,
306
+ bottom,
307
+ topLeft,
308
+ topRight,
309
+ bottomRight,
310
+ bottomLeft,
311
+ curve,
312
+ )
346
313
  }
347
314
  }
348
315
 
349
- private fun applyNormalizedClipPx(
350
- left: Float,
351
- top: Float,
352
- right: Float,
353
- bottom: Float,
354
- radius: Float,
316
+ private fun storeShadow(
317
+ enabled: Boolean,
318
+ red: Float,
319
+ green: Float,
320
+ blue: Float,
321
+ alpha: Float,
322
+ offsetX: Float,
323
+ offsetY: Float,
324
+ blurRadius: Float,
325
+ spreadDistance: Float,
355
326
  ) {
356
- // Far edges are derived, not rounded independently: see outlineFarEdge.
357
- // Independent rounding breathes the emitted size by 1 px under pure
358
- // translation, which is the visible artifact once an animation's tail
359
- // slows below a pixel per frame.
360
- val nextOutlineLeft = outlineOrigin(left)
361
- val nextOutlineTop = outlineOrigin(top)
362
- val nextOutlineRight = outlineFarEdge(left, right)
363
- val nextOutlineBottom = outlineFarEdge(top, bottom)
364
- // Outline.setRoundRect collapses a degenerate integer rect to empty.
365
- // Use the exact emitted geometry as the single semantic source for
366
- // rendering, visibility, accessibility and hit testing.
367
- val isEmpty = outlineRectIsEmpty(
368
- nextOutlineLeft,
369
- nextOutlineTop,
370
- nextOutlineRight,
371
- nextOutlineBottom,
372
- )
373
- val outlineGeometryChanged = outlineUsesPath || outlineUsesFloatRoundRect || outlineChanged(
374
- nextOutlineLeft,
375
- nextOutlineTop,
376
- nextOutlineRight,
377
- nextOutlineBottom,
378
- radius,
379
- outlineLeft,
380
- outlineTop,
381
- outlineRight,
382
- outlineBottom,
383
- clipRadius,
327
+ val wasVisible = requestedShadowEnabled && requestedShadowAlpha > 0f
328
+ val isVisible = enabled && alpha > 0f
329
+ val paintChanged = red != requestedShadowRed ||
330
+ green != requestedShadowGreen || blue != requestedShadowBlue ||
331
+ alpha != requestedShadowAlpha || blurRadius != requestedShadowBlurRadius
332
+ val pathChanged = offsetX != requestedShadowOffsetX ||
333
+ offsetY != requestedShadowOffsetY ||
334
+ spreadDistance != requestedShadowSpreadDistance
335
+ requestedShadowEnabled = enabled
336
+ requestedShadowRed = red
337
+ requestedShadowGreen = green
338
+ requestedShadowBlue = blue
339
+ requestedShadowAlpha = alpha
340
+ requestedShadowOffsetX = offsetX
341
+ requestedShadowOffsetY = offsetY
342
+ requestedShadowBlurRadius = blurRadius
343
+ requestedShadowSpreadDistance = spreadDistance
344
+ if (!isVisible) {
345
+ if (wasVisible) invalidate()
346
+ return
347
+ }
348
+ if (paintChanged || boxShadowPaint == null) updateBoxShadowPaint()
349
+ if (pathChanged || !wasVisible || boxShadowPath == null) {
350
+ rebuildBoxShadowPath()
351
+ }
352
+ if (paintChanged || pathChanged || !wasVisible) invalidate()
353
+ }
354
+
355
+ private fun updateBoxShadowPaint() {
356
+ val paint = boxShadowPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).also {
357
+ boxShadowPaint = it
358
+ }
359
+ paint.color = Color.argb(
360
+ (requestedShadowAlpha * 255f).roundToInt(),
361
+ (requestedShadowRed * 255f).roundToInt(),
362
+ (requestedShadowGreen * 255f).roundToInt(),
363
+ (requestedShadowBlue * 255f).roundToInt(),
384
364
  )
365
+ val sigmaPx = requestedShadowBlurRadius * 0.5f
366
+ val maskRadius = if (sigmaPx > 0.5f) {
367
+ (sigmaPx - 0.5f) / 0.57735f
368
+ } else {
369
+ 0f
370
+ }
371
+ paint.maskFilter = if (maskRadius > 0f) {
372
+ BlurMaskFilter(maskRadius, BlurMaskFilter.Blur.NORMAL)
373
+ } else {
374
+ null
375
+ }
376
+ }
385
377
 
386
- clipLeft = left
387
- clipTop = top
388
- clipRight = right
389
- clipBottom = bottom
390
- clipRadius = radius
391
- clipTopLeftRadius = radius
392
- clipTopRightRadius = radius
393
- clipBottomRightRadius = radius
394
- clipBottomLeftRadius = radius
395
- clipCurveCode = CLIP_CURVE_CIRCULAR
396
- // Whatever integer rounding threw away, carried on this view's own
397
- // translation so the clip edge still lands where the driver asked
398
- // instead of snapping to the pixel grid while the content it clips
399
- // slides in floats. It is a single scalar per axis only because the far
400
- // edges are derived from the origin — with independently rounded edges
401
- // no one translation could place both.
402
- clipResidualX = left - nextOutlineLeft
403
- clipResidualY = top - nextOutlineTop
404
- applyClipPlacement()
405
-
406
- if (isEmpty != clipIsEmpty) {
407
- if (isEmpty) cancelAcceptedTouchStream()
408
- clipIsEmpty = isEmpty
409
- visibility = clipVisibility(isEmpty)
410
- importantForAccessibility = clipAccessibility(
411
- isEmpty,
412
- requestedImportantForAccessibility,
413
- )
378
+ private fun adjustedRadiusForSpread(radius: Float, spread: Float): Float {
379
+ val magnitude = abs(spread)
380
+ val multiplier = if (magnitude > 0f && radius < magnitude) {
381
+ 1f + (radius / magnitude - 1f).pow(3)
382
+ } else {
383
+ 1f
414
384
  }
385
+ return (radius + spread * multiplier).coerceAtLeast(0f)
386
+ }
415
387
 
416
- // An emptiness transition necessarily changes at least one emitted
417
- // edge, so the existing edge/radius key cannot skip invalidateOutline.
418
- if (!outlineGeometryChanged) return
388
+ private fun rebuildBoxShadowPath() {
389
+ if (!requestedShadowEnabled || requestedShadowAlpha <= 0f) return
390
+ val path = boxShadowPath ?: Path().also { boxShadowPath = it }
391
+ path.reset()
392
+ if (clipIsEmpty) return
393
+ val spread = requestedShadowSpreadDistance
394
+ val left = clipLeft - spread + requestedShadowOffsetX
395
+ val top = clipTop - spread + requestedShadowOffsetY
396
+ val right = clipRight + spread + requestedShadowOffsetX
397
+ val bottom = clipBottom + spread + requestedShadowOffsetY
398
+ if (right <= left || bottom <= top) return
399
+ appendRoundedRectPath(
400
+ path,
401
+ left,
402
+ top,
403
+ right,
404
+ bottom,
405
+ adjustedRadiusForSpread(clipTopLeftRadius, spread),
406
+ adjustedRadiusForSpread(clipTopRightRadius, spread),
407
+ adjustedRadiusForSpread(clipBottomRightRadius, spread),
408
+ adjustedRadiusForSpread(clipBottomLeftRadius, spread),
409
+ clipCurveCode,
410
+ )
411
+ }
419
412
 
420
- outlineUsesPath = false
421
- outlineUsesFloatRoundRect = false
422
- clipPath.reset()
423
- outlineLeft = nextOutlineLeft
424
- outlineTop = nextOutlineTop
425
- outlineRight = nextOutlineRight
426
- outlineBottom = nextOutlineBottom
427
- // Schedules the traversal too: invalidateOutline() ends in
428
- // invalidateViewProperty(), which damages this view in its parent up
429
- // to ViewRootImpl.scheduleTraversals(). A plain invalidate() on top
430
- // would only add PFLAG_INVALIDATED, forcing a display-list re-record
431
- // every frame to restage an outline the RenderNode applies as a
432
- // property.
433
- invalidateOutline()
413
+ override fun dispatchDraw(canvas: Canvas) {
414
+ val hostSaveCount = canvas.save()
415
+ canvas.clipRect(0f, 0f, width.toFloat(), height.toFloat())
416
+ 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
+ super.dispatchDraw(canvas)
434
+ } finally {
435
+ canvas.restoreToCount(hostSaveCount)
436
+ }
434
437
  }
435
438
 
436
- private fun applyNormalizedClipV2Px(
439
+ private fun applyCanonicalClipPx(
437
440
  left: Float,
438
441
  top: Float,
439
442
  right: Float,
@@ -444,24 +447,9 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
444
447
  bottomLeftRadius: Float,
445
448
  curveCode: Int,
446
449
  ) {
447
- val radiiAreUniform = topLeftRadius == topRightRadius &&
448
- topLeftRadius == bottomRightRadius &&
449
- topLeftRadius == bottomLeftRadius
450
- val needsPath = curveCode == CLIP_CURVE_CONTINUOUS || !radiiAreUniform
451
- if (!needsPath) {
452
- applyNormalizedFloatRoundRectV2Px(
453
- left,
454
- top,
455
- right,
456
- bottom,
457
- topLeftRadius,
458
- )
459
- return
460
- }
461
-
462
450
  val isEmpty = right <= left || bottom <= top
463
- val outlineGeometryChanged = !outlineUsesPath ||
464
- left != clipLeft || top != clipTop || right != clipRight ||
451
+ val geometryChanged = left != clipLeft || top != clipTop ||
452
+ right != clipRight ||
465
453
  bottom != clipBottom || topLeftRadius != clipTopLeftRadius ||
466
454
  topRightRadius != clipTopRightRadius ||
467
455
  bottomRightRadius != clipBottomRightRadius ||
@@ -471,122 +459,60 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
471
459
  clipTop = top
472
460
  clipRight = right
473
461
  clipBottom = bottom
474
- clipRadius = if (radiiAreUniform) topLeftRadius else 0f
462
+ val radiiAreUniform = topLeftRadius == topRightRadius &&
463
+ topLeftRadius == bottomRightRadius &&
464
+ topLeftRadius == bottomLeftRadius
475
465
  clipTopLeftRadius = topLeftRadius
476
466
  clipTopRightRadius = topRightRadius
477
467
  clipBottomRightRadius = bottomRightRadius
478
468
  clipBottomLeftRadius = bottomLeftRadius
479
469
  clipCurveCode = curveCode
480
- // Path outlines retain physical-pixel floats, so unlike the legacy
481
- // integer round rect they need no residual translation compensation.
482
- clipResidualX = 0f
483
- clipResidualY = 0f
484
- applyClipPlacement()
485
-
486
- if (isEmpty != clipIsEmpty) {
487
- if (isEmpty) cancelAcceptedTouchStream()
488
- clipIsEmpty = isEmpty
489
- visibility = clipVisibility(isEmpty)
490
- importantForAccessibility = clipAccessibility(
491
- isEmpty,
492
- requestedImportantForAccessibility,
493
- )
494
- }
495
-
496
- if (!outlineGeometryChanged) return
497
-
498
- outlineUsesPath = true
499
- outlineUsesFloatRoundRect = false
500
- clipPath.reset()
501
- if (!isEmpty) {
502
- appendRoundedRectPath(
503
- clipPath,
504
- left,
505
- top,
506
- right,
507
- bottom,
508
- topLeftRadius,
509
- topRightRadius,
510
- bottomRightRadius,
511
- bottomLeftRadius,
512
- curveCode,
513
- )
514
- }
515
- invalidateOutline()
516
- }
470
+ applyContentTransform()
517
471
 
518
- /**
519
- * Protocol V2 uniform circular geometry stays entirely in float physical
520
- * pixels. V1 intentionally continues through applyNormalizedClipPx so its
521
- * integer outline and residual placement remain byte-for-byte compatible.
522
- */
523
- private fun applyNormalizedFloatRoundRectV2Px(
524
- left: Float,
525
- top: Float,
526
- right: Float,
527
- bottom: Float,
528
- radius: Float,
529
- ) {
530
- val isEmpty = right <= left || bottom <= top
531
- val outlineGeometryChanged = outlineUsesPath ||
532
- !outlineUsesFloatRoundRect ||
533
- left != clipLeft || top != clipTop || right != clipRight ||
534
- bottom != clipBottom || radius != clipRadius
472
+ clipIsEmpty = isEmpty
473
+ reapplyClipPresentation()
535
474
 
536
- clipLeft = left
537
- clipTop = top
538
- clipRight = right
539
- clipBottom = bottom
540
- clipRadius = radius
541
- clipTopLeftRadius = radius
542
- clipTopRightRadius = radius
543
- clipBottomRightRadius = radius
544
- clipBottomLeftRadius = radius
545
- clipCurveCode = CLIP_CURVE_CIRCULAR
546
- clipResidualX = 0f
547
- clipResidualY = 0f
548
- applyClipPlacement()
549
-
550
- if (isEmpty != clipIsEmpty) {
551
- if (isEmpty) cancelAcceptedTouchStream()
552
- clipIsEmpty = isEmpty
553
- visibility = clipVisibility(isEmpty)
554
- importantForAccessibility = clipAccessibility(
555
- isEmpty,
556
- requestedImportantForAccessibility,
557
- )
558
- }
475
+ if (!geometryChanged) return
559
476
 
560
- if (!outlineGeometryChanged) return
561
- outlineUsesPath = false
562
- outlineUsesFloatRoundRect = true
563
477
  clipPath.reset()
564
478
  if (!isEmpty) {
565
- clipPath.addRoundRect(
566
- left,
567
- top,
568
- right,
569
- bottom,
570
- radius,
571
- radius,
572
- Path.Direction.CW,
573
- )
479
+ if (curveCode == CLIP_CURVE_CIRCULAR && radiiAreUniform) {
480
+ clipPath.addRoundRect(
481
+ left,
482
+ top,
483
+ right,
484
+ bottom,
485
+ topLeftRadius,
486
+ topLeftRadius,
487
+ Path.Direction.CW,
488
+ )
489
+ } else {
490
+ appendRoundedRectPath(
491
+ clipPath,
492
+ left,
493
+ top,
494
+ right,
495
+ bottom,
496
+ topLeftRadius,
497
+ topRightRadius,
498
+ bottomRightRadius,
499
+ bottomLeftRadius,
500
+ curveCode,
501
+ )
502
+ }
574
503
  }
575
- invalidateOutline()
504
+ rebuildBoxShadowPath()
505
+ if (supportsPathOutlineClipping) {
506
+ clipContainer.invalidateOutline()
507
+ } else {
508
+ clipContainer.invalidate()
509
+ }
510
+ if (requestedShadowEnabled) invalidate()
576
511
  }
577
512
 
578
- /**
579
- * Single writer for both translations. The view carries the sub-pixel
580
- * remainder of the clip origin; the content container subtracts it back out
581
- * so only the clip edge moves and the content stays exactly where the
582
- * driver put it. Every write self-dedupes inside View.setTranslationX, so
583
- * calling this on an unchanged frame costs four float comparisons.
584
- */
585
- private fun applyClipPlacement() {
586
- super.setTranslationX(userTranslationX + clipResidualX)
587
- super.setTranslationY(userTranslationY + clipResidualY)
588
- contentContainer.translationX = requestedContentTranslateX - clipResidualX
589
- contentContainer.translationY = requestedContentTranslateY - clipResidualY
513
+ private fun applyContentTransform() {
514
+ contentContainer.translationX = requestedContentTranslateX
515
+ contentContainer.translationY = requestedContentTranslateY
590
516
  // Scale is intentionally centered and lives on the content only. View
591
517
  // translation properties are applied independently of scale, so a
592
518
  // caller's tx/ty remains a physical-pixel offset rather than scaling
@@ -597,36 +523,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
597
523
  contentContainer.scaleY = requestedContentScale
598
524
  }
599
525
 
600
- // The clip's sub-pixel placement and the consumer's `transform` prop share
601
- // one property, so they are composed rather than allowed to overwrite each
602
- // other. RN routes every transform write through these setters
603
- // (BaseViewManager.setTransformProperty), so intercepting them is enough.
604
- //
605
- // The getters are deliberately NOT overridden to hide the residual:
606
- // View.setTranslationX dedupes by calling getTranslationX() virtually, so
607
- // reporting the consumer's value there would make every write look like a
608
- // change and re-invalidate the view on frames where nothing moved. The cost
609
- // of leaving them alone is that a read-modify-write of translationX picks up
610
- // the residual twice — sub-pixel, and nothing in RN's transform path reads
611
- // back before writing.
612
- override fun setTranslationX(translationX: Float) {
613
- userTranslationX = translationX
614
- super.setTranslationX(translationX + clipResidualX)
615
- }
616
-
617
- override fun setTranslationY(translationY: Float) {
618
- userTranslationY = translationY
619
- super.setTranslationY(translationY + clipResidualY)
620
- }
621
-
622
526
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
623
527
  super.onSizeChanged(w, h, oldw, oldh)
528
+ clipContainer.layout(0, 0, w, h)
624
529
  contentContainer.layout(0, 0, w, h)
625
- applyClipPlacement()
530
+ applyContentTransform()
626
531
  if (boundDriverId != 0.0) {
627
- // Driver deliveries are pre-normalized in C++ against the pushed
628
- // host metrics; refresh them and let the registry redeliver the
629
- // visible value synchronously.
532
+ // Host metrics only gate lifecycle readiness. Redelivery remains
533
+ // raw and canonical; this view owns the final viewport crop.
630
534
  SmoothClipBindings.nativeSetViewHostGeometry(
631
535
  boundDriverId,
632
536
  this,
@@ -641,6 +545,7 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
641
545
  // authoritative command wins over the driver value on resize.
642
546
  applyRequestedGeometry()
643
547
  }
548
+ reapplyClipPresentation()
644
549
  }
645
550
 
646
551
  override fun onAttachedToWindow() {
@@ -695,89 +600,33 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
695
600
  }
696
601
 
697
602
  private fun containsRoundedPoint(x: Float, y: Float): Boolean {
698
- // The event arrives in this view's local space, which the parent has
699
- // already inverse-transformed by the residual translation. Adding it
700
- // back maps the point into the space clipLeft/clipTop are expressed in,
701
- // so hit testing stays against the geometry the driver delivered — the
702
- // same test as before the residual existed.
703
- if (outlineUsesPath) {
704
- // Reuse the exact cubic path supplied to Outline.setPath: hit
705
- // testing must follow the rendered aperture, including its
706
- // portable continuous-curve approximation.
707
- return !clipIsEmpty && containsPathPoint(clipPath, x, y)
708
- }
709
- return containsRoundedPointPx(
710
- x + clipResidualX,
711
- y + clipResidualY,
712
- clipLeft,
713
- clipTop,
714
- clipRight,
715
- clipBottom,
716
- clipRadius,
717
- clipIsEmpty,
718
- )
603
+ // Reuse the exact path supplied to the Outline. Hit testing therefore
604
+ // follows the rendered aperture, including continuous corners.
605
+ return !clipIsEmpty && containsPathPoint(clipPath, x, y)
719
606
  }
720
607
 
721
608
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
722
- when (event.actionMasked) {
723
- MotionEvent.ACTION_DOWN -> {
724
- clearTouchState()
725
- acceptingTouchStream = containsRoundedPoint(event.x, event.y)
726
- if (!acceptingTouchStream) return false
727
- val accepted = super.dispatchTouchEvent(event)
728
- if (accepted) {
729
- retainTouchEvent(event)
730
- } else {
731
- clearTouchState()
732
- }
733
- return accepted
734
- }
735
- MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
736
- val result = if (acceptingTouchStream) {
737
- super.dispatchTouchEvent(event)
738
- } else {
739
- false
740
- }
741
- clearTouchState()
742
- return result
743
- }
609
+ val action = event.actionMasked
610
+ if (action == MotionEvent.ACTION_DOWN) {
611
+ // The aperture gates admission only. Once accepted, the stream
612
+ // belongs to Android until its real UP or CANCEL arrives.
613
+ clearTouchState()
614
+ if (!containsRoundedPoint(event.x, event.y)) return false
615
+ acceptingTouchStream = true
744
616
  }
745
617
 
746
618
  if (!acceptingTouchStream) return false
747
619
  val result = super.dispatchTouchEvent(event)
748
- retainTouchEvent(event)
620
+ if ((action == MotionEvent.ACTION_DOWN && !result) ||
621
+ action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP
622
+ ) {
623
+ clearTouchState()
624
+ }
749
625
  return result
750
626
  }
751
627
 
752
- private fun retainTouchEvent(event: MotionEvent) {
753
- retainedTouchEvent?.recycle()
754
- retainedTouchEvent = MotionEvent.obtain(event)
755
- }
756
-
757
628
  private fun clearTouchState() {
758
629
  acceptingTouchStream = false
759
- retainedTouchEvent?.recycle()
760
- retainedTouchEvent = null
761
- }
762
-
763
- private fun cancelAcceptedTouchStream() {
764
- val latest = retainedTouchEvent
765
- if (!acceptingTouchStream || latest == null) {
766
- clearTouchState()
767
- return
768
- }
769
- val cancel = MotionEvent.obtain(latest)
770
- cancel.action = MotionEvent.ACTION_CANCEL
771
- // Terminal BEFORE dispatch: a child's synchronous cancel handler can
772
- // re-enter geometry application, and a nested emptiness transition
773
- // must observe an already-ended stream instead of synthesizing a
774
- // second cancel for it.
775
- clearTouchState()
776
- try {
777
- super.dispatchTouchEvent(cancel)
778
- } finally {
779
- cancel.recycle()
780
- }
781
630
  }
782
631
 
783
632
  fun setRequestedImportantForAccessibility(value: Int) {
@@ -785,14 +634,24 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
785
634
  reapplyClipPresentation()
786
635
  }
787
636
 
637
+ @DoNotStrip
638
+ fun setAutonomousMotion(active: Boolean) {
639
+ if (autonomousMotion == active) return
640
+ autonomousMotion = active
641
+ reapplyClipPresentation()
642
+ }
643
+
788
644
  fun reapplyClipPresentation() {
789
- val expectedVisibility = clipVisibility(clipIsEmpty)
645
+ val apertureVisible = apertureIntersectsHost()
646
+ val expectedVisibility = renderVisibility(
647
+ apertureVisible || shadowIntersectsHost(),
648
+ )
790
649
  if (visibility != expectedVisibility) {
791
650
  visibility = expectedVisibility
792
651
  }
793
652
 
794
653
  val expectedAccessibility = clipAccessibility(
795
- clipIsEmpty,
654
+ autonomousMotion || !apertureVisible,
796
655
  requestedImportantForAccessibility,
797
656
  )
798
657
  if (importantForAccessibility != expectedAccessibility) {
@@ -800,6 +659,29 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
800
659
  }
801
660
  }
802
661
 
662
+ private fun apertureIntersectsHost(): Boolean =
663
+ !clipIsEmpty && clipRight > 0f && clipBottom > 0f &&
664
+ clipLeft < width.toFloat() && clipTop < height.toFloat()
665
+
666
+ private fun shadowIntersectsHost(): Boolean {
667
+ if (clipIsEmpty || !requestedShadowEnabled || requestedShadowAlpha <= 0f) {
668
+ return false
669
+ }
670
+ val spread = requestedShadowSpreadDistance
671
+ val pathLeft = clipLeft - spread + requestedShadowOffsetX
672
+ val pathTop = clipTop - spread + requestedShadowOffsetY
673
+ val pathRight = clipRight + spread + requestedShadowOffsetX
674
+ val pathBottom = clipBottom + spread + requestedShadowOffsetY
675
+ if (pathRight <= pathLeft || pathBottom <= pathTop) return false
676
+
677
+ // CSS blur is specified as a diameter-like radius. Expanding by the
678
+ // full value is conservative and prevents culling a faint blur tail.
679
+ val blurOutset = requestedShadowBlurRadius
680
+ return pathRight + blurOutset > 0f && pathBottom + blurOutset > 0f &&
681
+ pathLeft - blurOutset < width.toFloat() &&
682
+ pathTop - blurOutset < height.toFloat()
683
+ }
684
+
803
685
  fun resetClipState() {
804
686
  clearTouchState()
805
687
  commandIsAuthoritative = false
@@ -807,7 +689,6 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
807
689
  requestedY = 0f
808
690
  requestedWidth = 0f
809
691
  requestedHeight = 0f
810
- requestedRadius = 0f
811
692
  requestedTopLeftRadius = 0f
812
693
  requestedTopRightRadius = 0f
813
694
  requestedBottomRightRadius = 0f
@@ -816,17 +697,19 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
816
697
  requestedContentTranslateX = 0f
817
698
  requestedContentTranslateY = 0f
818
699
  requestedContentScale = 1f
819
- requestedUsesV2Geometry = false
820
- clipResidualX = 0f
821
- clipResidualY = 0f
822
- applyClipPlacement()
823
- outlineUsesPath = false
824
- outlineUsesFloatRoundRect = false
700
+ requestedShadowEnabled = false
701
+ requestedShadowRed = 0f
702
+ requestedShadowGreen = 0f
703
+ requestedShadowBlue = 0f
704
+ requestedShadowAlpha = 1f
705
+ requestedShadowOffsetX = 0f
706
+ requestedShadowOffsetY = 0f
707
+ requestedShadowBlurRadius = 0f
708
+ requestedShadowSpreadDistance = 0f
709
+ boxShadowPaint = null
710
+ boxShadowPath = null
711
+ applyContentTransform()
825
712
  clipPath.reset()
826
- outlineLeft = 0
827
- outlineTop = 0
828
- outlineRight = 0
829
- outlineBottom = 0
830
713
  requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
831
714
  applyRequestedGeometry()
832
715
  }