react-native-smooth-clip-view 0.4.4 → 0.4.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/CHANGELOG.md +18 -0
- package/README.md +9 -0
- package/android/src/main/cpp/SmoothClipBindings.cpp +4 -2
- package/android/src/main/cpp/SmoothClipRegistry.cpp +1 -2
- package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +191 -10
- package/cpp/SmoothClipAnimationCurve.h +16 -3
- package/ios/SmoothClipGeometry.h +8 -7
- package/ios/SmoothClipRegistry.mm +1 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.6](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.6) — 2026-09-20
|
|
4
|
+
|
|
5
|
+
- Draw `continuous` corners on Android as a Figma smoothed corner (smoothing
|
|
6
|
+
0.6) instead of a single cubic pulled towards the corner point. The apex now
|
|
7
|
+
matches a circular corner of the same radius and the shoulders ease in from
|
|
8
|
+
1.6 × the radius, so a radius reads the same as on iOS and as
|
|
9
|
+
`react-native-fast-squircle` at `cornerSmoothing={0.6}`. Clip, shadow, and hit
|
|
10
|
+
testing share the path. The per-frame rebuild still allocates nothing and
|
|
11
|
+
does no trigonometry unless a shoulder has to be shortened to fit.
|
|
12
|
+
Existing Android `continuous` clips become visibly rounder at the same radius.
|
|
13
|
+
|
|
14
|
+
## [0.4.5](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.5) — 2026-09-20
|
|
15
|
+
|
|
16
|
+
- Run native `animateTo` for uniform `continuous` clips. The autonomous gate now
|
|
17
|
+
requires uniform corner radii and an unchanged curve instead of circular
|
|
18
|
+
corners, so iOS animates `cornerRadius` under a constant `cornerCurve` with no
|
|
19
|
+
mask. Unequal radii and curve changes still return `null`.
|
|
20
|
+
|
|
3
21
|
## [0.4.4](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.4) — 2026-09-11
|
|
4
22
|
|
|
5
23
|
- Allow the Expo 56 runtime baseline (React Native 0.85.3, Reanimated 4.3.1, Worklets 0.8.3).
|
package/README.md
CHANGED
|
@@ -214,6 +214,15 @@ APIs share validation, run ownership, and completion behavior.
|
|
|
214
214
|
reject the complete presentation. Spring opacity is clamped for rendering
|
|
215
215
|
without clamping its internal spring state.
|
|
216
216
|
- Circular and continuous curves and independent corner radii are supported.
|
|
217
|
+
- `continuous` is the platform squircle: `CALayer.cornerCurve` on iOS for
|
|
218
|
+
uniform radii, and a Figma smoothed corner at smoothing 0.6 on Android (the
|
|
219
|
+
shape `react-native-fast-squircle` draws at `cornerSmoothing={0.6}`). The apex
|
|
220
|
+
matches a circular corner of the same radius; the shoulders start 1.6 × the
|
|
221
|
+
radius from the corner. The two platforms are close, not pixel-identical.
|
|
222
|
+
- Native `animateTo` runs need uniform corner radii and one curve for the whole
|
|
223
|
+
run, so a uniform `continuous` clip animates natively. Unequal radii or a
|
|
224
|
+
curve change are static-only: set them with `setFrame`; `animateTo` returns
|
|
225
|
+
`null`.
|
|
217
226
|
- One outset `boxShadow` is supported. It escapes the aperture but not the host.
|
|
218
227
|
- A fully off-host aperture is not touchable, even when only its shadow overlaps.
|
|
219
228
|
- Descendant accessibility is hidden during autonomous native motion and restored
|
|
@@ -386,8 +386,10 @@ void installBindings(
|
|
|
386
386
|
runtime, bindings, "supportsAutonomousComplexPathAnimation", 0,
|
|
387
387
|
[](Runtime &, const Value &, const Value *, size_t) -> Value {
|
|
388
388
|
// Static complex clipping is available. Autonomous complex-path
|
|
389
|
-
// animation
|
|
390
|
-
//
|
|
389
|
+
// animation (unequal radii or a curve change; uniform continuous
|
|
390
|
+
// corners are scalar and already run natively) is deliberately gated
|
|
391
|
+
// off until physical-device sampling proves the release tolerance and
|
|
392
|
+
// frame-time requirements.
|
|
391
393
|
return Value(false);
|
|
392
394
|
});
|
|
393
395
|
|
|
@@ -1235,8 +1235,7 @@ int32_t startGroupCommon(
|
|
|
1235
1235
|
: visiblePresentation(entry.driverId, iterator->second);
|
|
1236
1236
|
if (!isFinitePresentation(resolvedStart) ||
|
|
1237
1237
|
!isFinitePresentation(entry.target) ||
|
|
1238
|
-
!
|
|
1239
|
-
!isAutonomousUniformCircular(entry.target)) {
|
|
1238
|
+
!isAutonomousUniformPair(resolvedStart, entry.target)) {
|
|
1240
1239
|
return 0;
|
|
1241
1240
|
}
|
|
1242
1241
|
if (kind == AnimationKind::Spring) {
|
|
@@ -2,8 +2,11 @@ package com.smoothclipview
|
|
|
2
2
|
|
|
3
3
|
import android.graphics.Path
|
|
4
4
|
import android.view.View
|
|
5
|
+
import kotlin.math.cos
|
|
5
6
|
import kotlin.math.max
|
|
6
7
|
import kotlin.math.min
|
|
8
|
+
import kotlin.math.sin
|
|
9
|
+
import kotlin.math.tan
|
|
7
10
|
|
|
8
11
|
internal const val CLIP_CURVE_CIRCULAR = 0
|
|
9
12
|
internal const val CLIP_CURVE_CONTINUOUS = 1
|
|
@@ -119,12 +122,40 @@ internal inline fun canonicalizeClipGeometryPx(
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
* Figma corner smoothing used by the continuous curve. 0.6 is the value Figma
|
|
126
|
+
* tuned against iOS continuous corners, and it is fixed so that every corner
|
|
127
|
+
* parameter below is a compile-time constant.
|
|
128
|
+
*/
|
|
129
|
+
private const val CONTINUOUS_SMOOTHING = 0.6f
|
|
130
|
+
|
|
131
|
+
// Unit-radius Figma parameters at CONTINUOUS_SMOOTHING. They are linear in the
|
|
132
|
+
// radius while the shoulder fits its budget, so the per-frame rebuild does no
|
|
133
|
+
// trigonometry. Derivation, with arc = 90deg * (1 - s) and beta = 45deg * s:
|
|
134
|
+
// ARC_CHORD = sqrt(2) * sin(arc / 2) axis distance covered by the arc
|
|
135
|
+
// C = tan(beta / 2) * cos(beta), D = tan(beta / 2) * sin(beta)
|
|
136
|
+
// B = (1 + s - ARC_CHORD - C - D) / 3, A = 2 * B
|
|
137
|
+
// ARC_HANDLE = 4/3 * tan(arc / 4) cubic handle of the circular arc
|
|
138
|
+
private const val CONTINUOUS_A = 0.5600525f
|
|
139
|
+
private const val CONTINUOUS_B = 0.28002625f
|
|
140
|
+
private const val CONTINUOUS_C = 0.21391174f
|
|
141
|
+
private const val CONTINUOUS_D = 0.10899348f
|
|
142
|
+
private const val CONTINUOUS_ARC_CHORD = 0.43701602f
|
|
143
|
+
private const val CONTINUOUS_ARC_HANDLE = 0.21117925f
|
|
144
|
+
private const val CONTINUOUS_COS_BETA = 0.8910065f
|
|
145
|
+
private const val CONTINUOUS_SIN_BETA = 0.4539905f
|
|
146
|
+
private const val QUARTER_PI = 0.7853982f
|
|
147
|
+
private const val SQRT_2 = 1.4142135f
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Emits one rounded rectangle. Circular uses the standard quarter-circle
|
|
151
|
+
* cubic per corner. Continuous is the Figma smoothed corner at smoothing 0.6:
|
|
152
|
+
* a cubic shoulder that leaves the edge at 1.6 * radius, the real radius-R
|
|
153
|
+
* circle through the apex, and a mirrored shoulder. The apex therefore sits
|
|
154
|
+
* where a circular corner's does; only the shoulders are longer and eased. It
|
|
155
|
+
* is the same family as Apple's continuous corner, not pixel-identical to it.
|
|
156
|
+
*
|
|
157
|
+
* This runs every animation frame: it allocates nothing, and a continuous
|
|
158
|
+
* corner is always three cubics (the arc included), whatever its radius.
|
|
128
159
|
*/
|
|
129
160
|
internal fun appendRoundedRectPath(
|
|
130
161
|
path: Path,
|
|
@@ -138,12 +169,39 @@ internal fun appendRoundedRectPath(
|
|
|
138
169
|
bottomLeftRadius: Float,
|
|
139
170
|
curveCode: Int,
|
|
140
171
|
) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
if (curveCode == CLIP_CURVE_CONTINUOUS) {
|
|
173
|
+
val width = right - left
|
|
174
|
+
val height = bottom - top
|
|
175
|
+
appendContinuousCorner(
|
|
176
|
+
path, true, right, top, 1f, 0f, 0f, 1f, topRightRadius,
|
|
177
|
+
continuousBudget(
|
|
178
|
+
topRightRadius, topLeftRadius, width, bottomRightRadius, height,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
appendContinuousCorner(
|
|
182
|
+
path, false, right, bottom, 0f, 1f, -1f, 0f, bottomRightRadius,
|
|
183
|
+
continuousBudget(
|
|
184
|
+
bottomRightRadius, topRightRadius, height, bottomLeftRadius, width,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
appendContinuousCorner(
|
|
188
|
+
path, false, left, bottom, -1f, 0f, 0f, -1f, bottomLeftRadius,
|
|
189
|
+
continuousBudget(
|
|
190
|
+
bottomLeftRadius, bottomRightRadius, width, topLeftRadius, height,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
appendContinuousCorner(
|
|
194
|
+
path, false, left, top, 0f, -1f, 1f, 0f, topLeftRadius,
|
|
195
|
+
continuousBudget(
|
|
196
|
+
topLeftRadius, bottomLeftRadius, height, topRightRadius, width,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
path.close()
|
|
200
|
+
return
|
|
145
201
|
}
|
|
146
202
|
|
|
203
|
+
val coefficient = 0.5522848f
|
|
204
|
+
|
|
147
205
|
path.moveTo(left + topLeftRadius, top)
|
|
148
206
|
path.lineTo(right - topRightRadius, top)
|
|
149
207
|
path.cubicTo(
|
|
@@ -184,6 +242,129 @@ internal fun appendRoundedRectPath(
|
|
|
184
242
|
path.close()
|
|
185
243
|
}
|
|
186
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Edge length one corner's shoulder may use. Each side is shared with the
|
|
247
|
+
* neighbouring corner in proportion to the radii, so two shoulders never
|
|
248
|
+
* overlap. Uniform radii give min(width, height) / 2, Figma's own budget.
|
|
249
|
+
*/
|
|
250
|
+
private fun continuousBudget(
|
|
251
|
+
radius: Float,
|
|
252
|
+
incomingNeighbourRadius: Float,
|
|
253
|
+
incomingSide: Float,
|
|
254
|
+
outgoingNeighbourRadius: Float,
|
|
255
|
+
outgoingSide: Float,
|
|
256
|
+
): Float {
|
|
257
|
+
val incoming = if (incomingNeighbourRadius > 0f) {
|
|
258
|
+
incomingSide * radius / (radius + incomingNeighbourRadius)
|
|
259
|
+
} else {
|
|
260
|
+
incomingSide
|
|
261
|
+
}
|
|
262
|
+
val outgoing = if (outgoingNeighbourRadius > 0f) {
|
|
263
|
+
outgoingSide * radius / (radius + outgoingNeighbourRadius)
|
|
264
|
+
} else {
|
|
265
|
+
outgoingSide
|
|
266
|
+
}
|
|
267
|
+
return min(incoming, outgoing)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Appends one continuous corner, travelling clockwise. (ux, uy) is the unit
|
|
272
|
+
* direction of the edge running into the corner point and (vx, vy) of the edge
|
|
273
|
+
* leaving it, so one body serves all four orientations.
|
|
274
|
+
*/
|
|
275
|
+
private fun appendContinuousCorner(
|
|
276
|
+
path: Path,
|
|
277
|
+
isFirst: Boolean,
|
|
278
|
+
cornerX: Float,
|
|
279
|
+
cornerY: Float,
|
|
280
|
+
ux: Float,
|
|
281
|
+
uy: Float,
|
|
282
|
+
vx: Float,
|
|
283
|
+
vy: Float,
|
|
284
|
+
radius: Float,
|
|
285
|
+
budget: Float,
|
|
286
|
+
) {
|
|
287
|
+
if (radius <= 0f) {
|
|
288
|
+
// A plain vertex: an all-square clip stays a rectangle the renderer
|
|
289
|
+
// can recognise, instead of a path of degenerate curves.
|
|
290
|
+
if (isFirst) path.moveTo(cornerX, cornerY) else path.lineTo(cornerX, cornerY)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
val shoulder: Float
|
|
295
|
+
val a: Float
|
|
296
|
+
val b: Float
|
|
297
|
+
val c: Float
|
|
298
|
+
val d: Float
|
|
299
|
+
val arcChord: Float
|
|
300
|
+
val arcHandle: Float
|
|
301
|
+
val cosBeta: Float
|
|
302
|
+
val sinBeta: Float
|
|
303
|
+
if ((1f + CONTINUOUS_SMOOTHING) * radius <= budget) {
|
|
304
|
+
shoulder = (1f + CONTINUOUS_SMOOTHING) * radius
|
|
305
|
+
a = CONTINUOUS_A * radius
|
|
306
|
+
b = CONTINUOUS_B * radius
|
|
307
|
+
c = CONTINUOUS_C * radius
|
|
308
|
+
d = CONTINUOUS_D * radius
|
|
309
|
+
arcChord = CONTINUOUS_ARC_CHORD * radius
|
|
310
|
+
arcHandle = CONTINUOUS_ARC_HANDLE * radius
|
|
311
|
+
cosBeta = CONTINUOUS_COS_BETA
|
|
312
|
+
sinBeta = CONTINUOUS_SIN_BETA
|
|
313
|
+
} else {
|
|
314
|
+
// The shoulder does not fit (near-pill shapes). Figma reduces the
|
|
315
|
+
// smoothing until it does; at zero this is an exact circular corner.
|
|
316
|
+
shoulder = max(budget, radius)
|
|
317
|
+
val smoothing = (shoulder / radius - 1f)
|
|
318
|
+
.coerceIn(0f, CONTINUOUS_SMOOTHING)
|
|
319
|
+
val beta = QUARTER_PI * smoothing
|
|
320
|
+
val halfArc = QUARTER_PI * (1f - smoothing)
|
|
321
|
+
val tanHalfBeta = tan(beta / 2f)
|
|
322
|
+
cosBeta = cos(beta)
|
|
323
|
+
sinBeta = sin(beta)
|
|
324
|
+
c = radius * tanHalfBeta * cosBeta
|
|
325
|
+
d = radius * tanHalfBeta * sinBeta
|
|
326
|
+
arcChord = radius * SQRT_2 * sin(halfArc)
|
|
327
|
+
arcHandle = radius * (4f / 3f) * tan(halfArc / 2f)
|
|
328
|
+
b = (shoulder - arcChord - c - d) / 3f
|
|
329
|
+
a = 2f * b
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
val startX = cornerX - ux * shoulder
|
|
333
|
+
val startY = cornerY - uy * shoulder
|
|
334
|
+
if (isFirst) path.moveTo(startX, startY) else path.lineTo(startX, startY)
|
|
335
|
+
|
|
336
|
+
val arcStartX = startX + ux * (a + b + c) + vx * d
|
|
337
|
+
val arcStartY = startY + uy * (a + b + c) + vy * d
|
|
338
|
+
path.cubicTo(
|
|
339
|
+
startX + ux * a,
|
|
340
|
+
startY + uy * a,
|
|
341
|
+
startX + ux * (a + b),
|
|
342
|
+
startY + uy * (a + b),
|
|
343
|
+
arcStartX,
|
|
344
|
+
arcStartY,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
val arcEndX = arcStartX + (ux + vx) * arcChord
|
|
348
|
+
val arcEndY = arcStartY + (uy + vy) * arcChord
|
|
349
|
+
path.cubicTo(
|
|
350
|
+
arcStartX + (ux * cosBeta + vx * sinBeta) * arcHandle,
|
|
351
|
+
arcStartY + (uy * cosBeta + vy * sinBeta) * arcHandle,
|
|
352
|
+
arcEndX - (ux * sinBeta + vx * cosBeta) * arcHandle,
|
|
353
|
+
arcEndY - (uy * sinBeta + vy * cosBeta) * arcHandle,
|
|
354
|
+
arcEndX,
|
|
355
|
+
arcEndY,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
path.cubicTo(
|
|
359
|
+
arcEndX + ux * d + vx * c,
|
|
360
|
+
arcEndY + uy * d + vy * c,
|
|
361
|
+
arcEndX + ux * d + vx * (b + c),
|
|
362
|
+
arcEndY + uy * d + vy * (b + c),
|
|
363
|
+
cornerX + vx * shoulder,
|
|
364
|
+
cornerY + vy * shoulder,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
187
368
|
/**
|
|
188
369
|
* Hit-tests the actual rendered path. Path has no float `contains` API, so we
|
|
189
370
|
* flatten that same instance to sub-pixel line segments for ACTION_DOWN. This
|
|
@@ -238,16 +238,29 @@ inline bool hasVisibleShadow(const Presentation &presentation) {
|
|
|
238
238
|
presentation.clip.width > 0 && presentation.clip.height > 0;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
inline bool
|
|
242
|
-
const Presentation &presentation) {
|
|
241
|
+
inline bool hasUniformRadii(const Presentation &presentation) {
|
|
243
242
|
const Geometry &clip = presentation.clip;
|
|
244
|
-
return
|
|
243
|
+
return radiiAreUniform(
|
|
245
244
|
resolvedRadius(clip.topLeftRadius, clip.radius),
|
|
246
245
|
resolvedRadius(clip.topRightRadius, clip.radius),
|
|
247
246
|
resolvedRadius(clip.bottomRightRadius, clip.radius),
|
|
248
247
|
resolvedRadius(clip.bottomLeftRadius, clip.radius));
|
|
249
248
|
}
|
|
250
249
|
|
|
250
|
+
/**
|
|
251
|
+
* An autonomous run may only interpolate scalar channels. Uniform radii with
|
|
252
|
+
* one curve family for the whole interval qualify: iOS animates
|
|
253
|
+
* CALayer.cornerRadius under a constant cornerCurve and Android rebuilds its
|
|
254
|
+
* fixed-topology path from the blended radius. Unequal radii or a curve change
|
|
255
|
+
* would need complex-path interpolation, which stays gated off.
|
|
256
|
+
*/
|
|
257
|
+
inline bool isAutonomousUniformPair(
|
|
258
|
+
const Presentation &from,
|
|
259
|
+
const Presentation &to) {
|
|
260
|
+
return from.clip.curve == to.clip.curve && hasUniformRadii(from) &&
|
|
261
|
+
hasUniformRadii(to);
|
|
262
|
+
}
|
|
263
|
+
|
|
251
264
|
/**
|
|
252
265
|
* Makes shadow appearance/disappearance a pure alpha fade. A disabled or
|
|
253
266
|
* transparent endpoint borrows the visible endpoint's style while retaining
|
package/ios/SmoothClipGeometry.h
CHANGED
|
@@ -149,13 +149,14 @@ NS_INLINE CGPathRef SmoothClipCreateRoundedRectPath(
|
|
|
149
149
|
const CGFloat minY = CGRectGetMinY(rect);
|
|
150
150
|
const CGFloat maxX = CGRectGetMaxX(rect);
|
|
151
151
|
const CGFloat maxY = CGRectGetMaxY(rect);
|
|
152
|
-
// Circular is the exact cubic-circle coefficient.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// continuous
|
|
158
|
-
//
|
|
152
|
+
// Circular is the exact cubic-circle coefficient. This continuous fallback
|
|
153
|
+
// uses kappa=1 so each cubic joins its straight edges with zero curvature
|
|
154
|
+
// in one segment, which keeps the topology Core Animation interpolates.
|
|
155
|
+
// It only feeds the unequal-radii mask and the shadow outline: uniform
|
|
156
|
+
// continuous shapes use CALayer.cornerCurve, Apple's platform-native
|
|
157
|
+
// compositor shape. Android draws continuous as a Figma smoothed corner
|
|
158
|
+
// instead, so this path does not mirror it. Hit testing derives from this
|
|
159
|
+
// rendered path rather than maintaining a second curve model.
|
|
159
160
|
const CGFloat controlFactor =
|
|
160
161
|
curve == SmoothClipCornerCurveContinuous
|
|
161
162
|
? 1.0
|
|
@@ -1275,8 +1275,7 @@ bool preflightGroupEntries(
|
|
|
1275
1275
|
: canonicalVisiblePresentation(iterator->second);
|
|
1276
1276
|
if (!isFinitePresentation(resolvedStart) ||
|
|
1277
1277
|
!isFinitePresentation(entry.target) ||
|
|
1278
|
-
!
|
|
1279
|
-
!isAutonomousUniformCircular(entry.target)) {
|
|
1278
|
+
!isAutonomousUniformPair(resolvedStart, entry.target)) {
|
|
1280
1279
|
return false;
|
|
1281
1280
|
}
|
|
1282
1281
|
if (kind == AnimationKind::Spring) {
|
package/package.json
CHANGED