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.
- package/README.md +62 -25
- package/android/CMakeLists.txt +0 -1
- package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
- package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
- package/android/src/main/cpp/SmoothClipRegistry.cpp +198 -189
- package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
- package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
- package/cpp/SmoothClipAnimationCurve.h +468 -0
- package/cpp/SmoothClipAnimationId.h +32 -0
- package/cpp/SmoothClipRegistry.h +11 -1
- package/cpp/SmoothClipVelocityTracker.h +32 -15
- package/ios/SmoothClipRegistry.mm +246 -97
- package/ios/SmoothClipTurboModule.cpp +4 -2
- package/ios/SmoothClipTurboModule.h +2 -1
- package/ios/SmoothClipView.mm +73 -16
- package/lib/module/NativeSmoothClipModule.js.map +1 -1
- package/lib/module/drivers.android.js +3 -1
- package/lib/module/drivers.android.js.map +1 -1
- package/lib/module/drivers.ios.js +3 -391
- package/lib/module/drivers.ios.js.map +1 -1
- package/lib/module/drivers.native.js +470 -0
- package/lib/module/drivers.native.js.map +1 -0
- package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
- package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
- package/lib/typescript/src/driverTypes.d.ts +6 -3
- package/lib/typescript/src/driverTypes.d.ts.map +1 -1
- package/lib/typescript/src/drivers.android.d.ts +2 -2
- package/lib/typescript/src/drivers.android.d.ts.map +1 -1
- package/lib/typescript/src/drivers.ios.d.ts +2 -4
- package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
- package/lib/typescript/src/drivers.native.d.ts +5 -0
- package/lib/typescript/src/drivers.native.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/NativeSmoothClipModule.ts +2 -1
- package/src/driverTypes.ts +6 -3
- package/src/drivers.android.ts +4 -2
- package/src/drivers.ios.ts +4 -687
- 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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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:
|
|
65
|
-
top:
|
|
66
|
-
right:
|
|
67
|
-
bottom:
|
|
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
|
|
76
|
-
top
|
|
77
|
-
right
|
|
78
|
-
bottom
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
326
|
-
|
|
395
|
+
clipResidualX = 0f
|
|
396
|
+
clipResidualY = 0f
|
|
397
|
+
applyClipPlacement()
|
|
327
398
|
outlineLeft = 0
|
|
328
399
|
outlineTop = 0
|
|
329
400
|
outlineRight = 0
|