react-native-smooth-clip-view 0.4.5 → 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,16 @@
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
+
3
14
  ## [0.4.5](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.5) — 2026-09-20
4
15
 
5
16
  - Run native `animateTo` for uniform `continuous` clips. The autonomous gate now
package/README.md CHANGED
@@ -214,6 +214,11 @@ 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.
217
222
  - Native `animateTo` runs need uniform corner radii and one curve for the whole
218
223
  run, so a uniform `continuous` clip animates natively. Unequal radii or a
219
224
  curve change are static-only: set them with `setFrame`; `animateTo` returns
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.4.5",
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",