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
|
-
*
|
|
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
|
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
|
package/package.json
CHANGED