react-native-smooth-clip-view 0.2.4 → 0.2.6

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 (40) hide show
  1. package/README.md +62 -25
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +198 -189
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
  10. package/cpp/SmoothClipAnimationCurve.h +468 -0
  11. package/cpp/SmoothClipAnimationId.h +32 -0
  12. package/cpp/SmoothClipRegistry.h +11 -1
  13. package/cpp/SmoothClipVelocityTracker.h +32 -15
  14. package/ios/SmoothClipRegistry.mm +246 -97
  15. package/ios/SmoothClipTurboModule.cpp +4 -2
  16. package/ios/SmoothClipTurboModule.h +2 -1
  17. package/ios/SmoothClipView.mm +73 -16
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/drivers.android.js +3 -1
  20. package/lib/module/drivers.android.js.map +1 -1
  21. package/lib/module/drivers.ios.js +3 -391
  22. package/lib/module/drivers.ios.js.map +1 -1
  23. package/lib/module/drivers.native.js +470 -0
  24. package/lib/module/drivers.native.js.map +1 -0
  25. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  27. package/lib/typescript/src/driverTypes.d.ts +6 -3
  28. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  29. package/lib/typescript/src/drivers.android.d.ts +2 -2
  30. package/lib/typescript/src/drivers.android.d.ts.map +1 -1
  31. package/lib/typescript/src/drivers.ios.d.ts +2 -4
  32. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts +5 -0
  34. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/NativeSmoothClipModule.ts +2 -1
  37. package/src/driverTypes.ts +6 -3
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -687
  40. package/src/drivers.native.ts +805 -0
@@ -54,17 +54,67 @@ internal inline fun normalizeClipGeometryPx(
54
54
  internal fun clipVisibility(isEmpty: Boolean): Int =
55
55
  if (isEmpty) View.INVISIBLE else View.VISIBLE
56
56
 
57
+ /** Rounded integer origin of an outline edge. */
58
+ internal fun outlineOrigin(value: Float): Int = value.roundToInt()
59
+
60
+ /**
61
+ * Rounded far edge, derived from the rounded origin plus the rounded extent.
62
+ *
63
+ * `Outline.setRoundRect` takes an int rect, and rounding the two edges
64
+ * independently makes `round(far) - round(origin)` alternate between the floor
65
+ * and the ceil of a *constant* extent as the origin's fraction sweeps: a pure
66
+ * translation breathes the emitted size by 1 px, frame after frame, while the
67
+ * content inside translates in floats. That oscillation is what reads as
68
+ * shimmer on the slow tail of an animation, where the per-frame delta drops
69
+ * below a pixel and the rounding becomes the motion.
70
+ *
71
+ * An int rect cannot hold origin, far edge and extent within half a pixel at
72
+ * once (extent = far - origin, so bounding both edges at 0.5 bounds the extent
73
+ * at 1.0); the only choice is which error oscillates. Deriving the far edge
74
+ * makes the emitted extent a pure function of the true extent — stable, error
75
+ * <= 0.5 px — and moves the slack onto the far edge, <= 1.0 px and static. A
76
+ * static bias does not read as jank; an alternating one does.
77
+ *
78
+ * Two consequences, both benign:
79
+ * - The far edge can overshoot the host bound by up to 1 px instead of 0.5. The
80
+ * view's own bounds already clip, so an over-large outline just means that
81
+ * edge is not clipped that frame — which independent rounding already
82
+ * produced whenever `round(far)` rounded up.
83
+ * - A sub-pixel extent (0 < extent < 0.5) rounds to a degenerate rect, which
84
+ * `Outline.setRoundRect` collapses to `setEmpty()`. Independent rounding hit
85
+ * that state on some frames and a 1 px rect on others, i.e. it flickered;
86
+ * deriving the far edge makes it consistent. The emitted integer rect also
87
+ * governs visibility, accessibility and hit testing, so a view never remains
88
+ * logically visible while its outline clips every pixel.
89
+ */
90
+ internal fun outlineFarEdge(origin: Float, far: Float): Int =
91
+ outlineOrigin(origin) + (far - origin).roundToInt()
92
+
93
+ /** Exactly mirrors the non-empty requirement of Outline.setRoundRect(Int...). */
94
+ internal fun outlineRectIsEmpty(
95
+ left: Int,
96
+ top: Int,
97
+ right: Int,
98
+ bottom: Int,
99
+ ): Boolean = right <= left || bottom <= top
100
+
57
101
  /**
58
102
  * Outline dedupe on the rounded integer physical-pixel edges actually emitted
59
- * to the outline provider. Sub-pixel motion that rounds to the cached integers
60
- * must not invalidate the outline; the radius stays a float comparison because
61
- * it is emitted to the outline as a float.
103
+ * to the outline provider. Takes the already-derived edges rather than raw
104
+ * floats so the caller rounds once per frame and the predicate can never drift
105
+ * from what the provider emits; the radius stays a float comparison because it
106
+ * is emitted to the outline as a float.
107
+ *
108
+ * Sub-pixel motion that rounds to the cached integers must NOT invalidate the
109
+ * outline — it is carried on the view's translation instead (see
110
+ * SmoothClipView.applyClipPlacement), which is both cheaper and smoother than
111
+ * restaging an identical rect.
62
112
  */
63
113
  internal fun outlineChanged(
64
- left: Float,
65
- top: Float,
66
- right: Float,
67
- bottom: Float,
114
+ left: Int,
115
+ top: Int,
116
+ right: Int,
117
+ bottom: Int,
68
118
  radius: Float,
69
119
  cachedLeft: Int,
70
120
  cachedTop: Int,
@@ -72,10 +122,10 @@ internal fun outlineChanged(
72
122
  cachedBottom: Int,
73
123
  cachedRadius: Float,
74
124
  ): Boolean =
75
- left.roundToInt() != cachedLeft ||
76
- top.roundToInt() != cachedTop ||
77
- right.roundToInt() != cachedRight ||
78
- bottom.roundToInt() != cachedBottom ||
125
+ left != cachedLeft ||
126
+ top != cachedTop ||
127
+ right != cachedRight ||
128
+ bottom != cachedBottom ||
79
129
  radius != cachedRadius
80
130
 
81
131
  internal fun clipAccessibility(
@@ -1,5 +1,6 @@
1
1
  package com.smoothclipview
2
2
 
3
+ import android.view.Choreographer
3
4
  import com.facebook.proguard.annotations.DoNotStrip
4
5
  import com.facebook.react.turbomodule.core.interfaces.BindingsInstallerHolder
5
6
 
@@ -59,4 +60,23 @@ internal object SmoothClipBindings {
59
60
  * while the JS runtime is still alive (module invalidate).
60
61
  */
61
62
  external fun nativeInvalidate()
63
+
64
+ private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
65
+ nativeOnFrame(frameTimeNanos)
66
+ }
67
+
68
+ /**
69
+ * Posts the registry frame loop into this thread's Java Choreographer.
70
+ * Plain postFrameCallback lands in the animation phase, so the advance
71
+ * runs in the same doFrame pass as Reanimated prop updates — always
72
+ * before the traversal draws. The C++ registry calls this (main thread
73
+ * only) and dedupes posts with its own scheduled flag.
74
+ */
75
+ @JvmStatic
76
+ fun scheduleFrame() {
77
+ Choreographer.getInstance().postFrameCallback(frameCallback)
78
+ }
79
+
80
+ /** Advances the frame loop with the doFrame vsync timestamp. */
81
+ external fun nativeOnFrame(frameTimeNanos: Long)
62
82
  }
@@ -39,6 +39,7 @@ class SmoothClipModule(context: ReactApplicationContext) :
39
39
  contentTranslateX: Double,
40
40
  contentTranslateY: Double,
41
41
  takeOwnership: Boolean,
42
+ overridePendingAnimation: Boolean,
42
43
  ) = Unit
43
44
 
44
45
  override fun beginInteraction(driverId: Double): WritableArray =
@@ -9,7 +9,6 @@ import com.facebook.proguard.annotations.DoNotStrip
9
9
  import com.facebook.react.uimanager.PixelUtil
10
10
  import com.facebook.react.uimanager.ThemedReactContext
11
11
  import com.facebook.react.views.view.ReactViewGroup
12
- import kotlin.math.roundToInt
13
12
 
14
13
  class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
15
14
  internal val contentContainer = ReactViewGroup(context)
@@ -32,6 +31,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
32
31
  private var outlineRight = 0
33
32
  private var outlineBottom = 0
34
33
  private var clipIsEmpty = true
34
+ // Sub-pixel remainder of the clip origin, carried on this view's own
35
+ // translation and subtracted back out of the content container.
36
+ private var clipResidualX = 0f
37
+ private var clipResidualY = 0f
38
+ // The consumer's `transform` translation, kept apart from the residual so
39
+ // the two compose instead of clobbering each other.
40
+ private var userTranslationX = 0f
41
+ private var userTranslationY = 0f
35
42
  private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
36
43
  private var acceptingTouchStream = false
37
44
 
@@ -110,14 +117,8 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
110
117
  requestedWidth = nextWidth
111
118
  requestedHeight = nextHeight
112
119
  requestedRadius = nextRadius
113
- if (requestedContentTranslateX != nextContentTranslateX) {
114
- requestedContentTranslateX = nextContentTranslateX
115
- contentContainer.translationX = nextContentTranslateX
116
- }
117
- if (requestedContentTranslateY != nextContentTranslateY) {
118
- requestedContentTranslateY = nextContentTranslateY
119
- contentContainer.translationY = nextContentTranslateY
120
- }
120
+ requestedContentTranslateX = nextContentTranslateX
121
+ requestedContentTranslateY = nextContentTranslateY
121
122
  applyRequestedGeometry()
122
123
  }
123
124
 
@@ -136,14 +137,11 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
136
137
  contentTranslateX: Float,
137
138
  contentTranslateY: Float,
138
139
  ) {
139
- if (requestedContentTranslateX != contentTranslateX) {
140
- requestedContentTranslateX = contentTranslateX
141
- contentContainer.translationX = contentTranslateX
142
- }
143
- if (requestedContentTranslateY != contentTranslateY) {
144
- requestedContentTranslateY = contentTranslateY
145
- contentContainer.translationY = contentTranslateY
146
- }
140
+ // Stored only; applyNormalizedClipPx computes the clip residual and
141
+ // applyClipPlacement writes both translations together, because the
142
+ // content's final offset depends on the residual as well.
143
+ requestedContentTranslateX = contentTranslateX
144
+ requestedContentTranslateY = contentTranslateY
147
145
  applyNormalizedClipPx(left, top, right, bottom, radius)
148
146
  }
149
147
 
@@ -168,12 +166,28 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
168
166
  bottom: Float,
169
167
  radius: Float,
170
168
  ) {
171
- val isEmpty = right <= left || bottom <= top
169
+ // Far edges are derived, not rounded independently: see outlineFarEdge.
170
+ // Independent rounding breathes the emitted size by 1 px under pure
171
+ // translation, which is the visible artifact once an animation's tail
172
+ // slows below a pixel per frame.
173
+ val nextOutlineLeft = outlineOrigin(left)
174
+ val nextOutlineTop = outlineOrigin(top)
175
+ val nextOutlineRight = outlineFarEdge(left, right)
176
+ val nextOutlineBottom = outlineFarEdge(top, bottom)
177
+ // Outline.setRoundRect collapses a degenerate integer rect to empty.
178
+ // Use the exact emitted geometry as the single semantic source for
179
+ // rendering, visibility, accessibility and hit testing.
180
+ val isEmpty = outlineRectIsEmpty(
181
+ nextOutlineLeft,
182
+ nextOutlineTop,
183
+ nextOutlineRight,
184
+ nextOutlineBottom,
185
+ )
172
186
  val outlineGeometryChanged = outlineChanged(
173
- left,
174
- top,
175
- right,
176
- bottom,
187
+ nextOutlineLeft,
188
+ nextOutlineTop,
189
+ nextOutlineRight,
190
+ nextOutlineBottom,
177
191
  radius,
178
192
  outlineLeft,
179
193
  outlineTop,
@@ -182,19 +196,20 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
182
196
  clipRadius,
183
197
  )
184
198
 
185
- if (!outlineGeometryChanged && isEmpty == clipIsEmpty) {
186
- return
187
- }
188
-
189
199
  clipLeft = left
190
200
  clipTop = top
191
201
  clipRight = right
192
202
  clipBottom = bottom
193
203
  clipRadius = radius
194
- outlineLeft = left.roundToInt()
195
- outlineTop = top.roundToInt()
196
- outlineRight = right.roundToInt()
197
- outlineBottom = bottom.roundToInt()
204
+ // Whatever integer rounding threw away, carried on this view's own
205
+ // translation so the clip edge still lands where the driver asked
206
+ // instead of snapping to the pixel grid while the content it clips
207
+ // slides in floats. It is a single scalar per axis only because the far
208
+ // edges are derived from the origin — with independently rounded edges
209
+ // no one translation could place both.
210
+ clipResidualX = left - nextOutlineLeft
211
+ clipResidualY = top - nextOutlineTop
212
+ applyClipPlacement()
198
213
 
199
214
  if (isEmpty != clipIsEmpty) {
200
215
  clipIsEmpty = isEmpty
@@ -206,9 +221,59 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
206
221
  if (isEmpty) acceptingTouchStream = false
207
222
  }
208
223
 
224
+ // An emptiness transition necessarily changes at least one emitted
225
+ // edge, so the existing edge/radius key cannot skip invalidateOutline.
226
+ if (!outlineGeometryChanged) return
227
+
228
+ outlineLeft = nextOutlineLeft
229
+ outlineTop = nextOutlineTop
230
+ outlineRight = nextOutlineRight
231
+ outlineBottom = nextOutlineBottom
232
+ // Schedules the traversal too: invalidateOutline() ends in
233
+ // invalidateViewProperty(), which damages this view in its parent up
234
+ // to ViewRootImpl.scheduleTraversals(). A plain invalidate() on top
235
+ // would only add PFLAG_INVALIDATED, forcing a display-list re-record
236
+ // every frame to restage an outline the RenderNode applies as a
237
+ // property.
209
238
  invalidateOutline()
210
239
  }
211
240
 
241
+ /**
242
+ * Single writer for both translations. The view carries the sub-pixel
243
+ * remainder of the clip origin; the content container subtracts it back out
244
+ * so only the clip edge moves and the content stays exactly where the
245
+ * driver put it. Every write self-dedupes inside View.setTranslationX, so
246
+ * calling this on an unchanged frame costs four float comparisons.
247
+ */
248
+ private fun applyClipPlacement() {
249
+ super.setTranslationX(userTranslationX + clipResidualX)
250
+ super.setTranslationY(userTranslationY + clipResidualY)
251
+ contentContainer.translationX = requestedContentTranslateX - clipResidualX
252
+ contentContainer.translationY = requestedContentTranslateY - clipResidualY
253
+ }
254
+
255
+ // The clip's sub-pixel placement and the consumer's `transform` prop share
256
+ // one property, so they are composed rather than allowed to overwrite each
257
+ // other. RN routes every transform write through these setters
258
+ // (BaseViewManager.setTransformProperty), so intercepting them is enough.
259
+ //
260
+ // The getters are deliberately NOT overridden to hide the residual:
261
+ // View.setTranslationX dedupes by calling getTranslationX() virtually, so
262
+ // reporting the consumer's value there would make every write look like a
263
+ // change and re-invalidate the view on frames where nothing moved. The cost
264
+ // of leaving them alone is that a read-modify-write of translationX picks up
265
+ // the residual twice — sub-pixel, and nothing in RN's transform path reads
266
+ // back before writing.
267
+ override fun setTranslationX(translationX: Float) {
268
+ userTranslationX = translationX
269
+ super.setTranslationX(translationX + clipResidualX)
270
+ }
271
+
272
+ override fun setTranslationY(translationY: Float) {
273
+ userTranslationY = translationY
274
+ super.setTranslationY(translationY + clipResidualY)
275
+ }
276
+
212
277
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
213
278
  super.onSizeChanged(w, h, oldw, oldh)
214
279
  contentContainer.layout(0, 0, w, h)
@@ -260,9 +325,14 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
260
325
  internal fun densityScale(): Double = PixelUtil.toPixelFromDIP(1f).toDouble()
261
326
 
262
327
  private fun containsRoundedPoint(x: Float, y: Float): Boolean {
328
+ // The event arrives in this view's local space, which the parent has
329
+ // already inverse-transformed by the residual translation. Adding it
330
+ // back maps the point into the space clipLeft/clipTop are expressed in,
331
+ // so hit testing stays against the geometry the driver delivered — the
332
+ // same test as before the residual existed.
263
333
  return containsRoundedPointPx(
264
- x,
265
- y,
334
+ x + clipResidualX,
335
+ y + clipResidualY,
266
336
  clipLeft,
267
337
  clipTop,
268
338
  clipRight,
@@ -322,8 +392,9 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
322
392
  requestedRadius = 0f
323
393
  requestedContentTranslateX = 0f
324
394
  requestedContentTranslateY = 0f
325
- contentContainer.translationX = 0f
326
- contentContainer.translationY = 0f
395
+ clipResidualX = 0f
396
+ clipResidualY = 0f
397
+ applyClipPlacement()
327
398
  outlineLeft = 0
328
399
  outlineTop = 0
329
400
  outlineRight = 0