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 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 is deliberately gated off until physical-device sampling
390
- // proves the release tolerance and frame-time requirements.
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
- !isAutonomousUniformCircular(resolvedStart) ||
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
- * Emits one fixed-topology rounded rectangle for both curve families.
123
- * Circular uses the standard quarter-circle cubic coefficient; continuous
124
- * keeps both control points at the rectangle corner (coefficient 1), yielding
125
- * zero endpoint curvature and a visibly smoother shoulder. "Continuous" is a
126
- * library-defined cross-platform curve, not a claim of pixel identity with
127
- * Apple's private continuous-corner implementation.
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
- val coefficient = if (curveCode == CLIP_CURVE_CONTINUOUS) {
142
- 1f
143
- } else {
144
- 0.5522848f
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 isAutonomousUniformCircular(
242
- const Presentation &presentation) {
241
+ inline bool hasUniformRadii(const Presentation &presentation) {
243
242
  const Geometry &clip = presentation.clip;
244
- return clip.curve == ClipCurve::Circular && radiiAreUniform(
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
@@ -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. The portable continuous
153
- // fallback uses kappa=1 so each cubic joins its straight edges with zero
154
- // curvature, matching the portable Android path. Hit testing derives
155
- // from this rendered path rather than maintaining a second curve model.
156
- // Uniform
157
- // continuous shapes use CALayer.cornerCurve for Apple's platform-native
158
- // compositor shape and are intentionally not pixel-identical cross-platform.
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
- !isAutonomousUniformCircular(resolvedStart) ||
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Layout-free animated rounded clipping for React Native Fabric",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",