Skip to content

fix(ios): honor corner-shape: squircle for non-uniform border radii - #11451

Open
aleclarson wants to merge 1 commit into
NativeScript:mainfrom
aleclarson:fix/corner-shape-non-uniform
Open

aleclarson wants to merge 1 commit into
NativeScript:mainfrom
aleclarson:fix/corner-shape-non-uniform

Conversation

@aleclarson

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

#11365 added corner-shape: squircle support by mapping it to CALayer.cornerCurve — but only on the uniform border-radius path, where the view's own cornerRadius does the rounding. Views with non-uniform radii (e.g. border-radius: 16px 16px 0 0, a bottom sheet with rounded top corners) are instead clipped by a CAShapeLayer mask built from custom CGPath generators — and those generators hardcode CGPathAddArcToPoint/CGPathAddArc, so corner-shape is silently ignored.

What is the new behavior?

The non-uniform path generators honor background.cornerShape. A new addSquircleArcToPoint helper draws each corner as a sampled n=4 superellipse (the curve the CSS spec defines for corner-shape: squircle, and the closest parametric approximation of Apple's continuous corner — CAShapeLayer has no cornerCurve equivalent for custom paths). It is wired into:

  • generateNonUniformBorderOuterClipPath (view mask, LayerMask.BORDER)
  • generateNonUniformBorderInnerClipPath (uniform-color border fill)
  • generateShadowLayerPaths (box-shadow inner/mask + shadow paths, both uniform and non-uniform radius cases)
  • generateNonUniformBorderOuterClipRoundedPath (bounds-animation path updates)

cornerShape: round output is byte-for-byte what it was before — the circular-arc calls remain untouched behind if (squircle) branches.

Implementation note: each quarter arc is emitted as a 12-segment polyline (SQUIRCLE_CORNER_STEPS). Corner masks are rasterized once per bounds change, so the sampling is visually indistinguishable at screen scale while keeping the code dependency-free. Happy to switch to cubic Bézier segments if reviewers prefer — the polyline keeps the math readable but a two-curve fit would shrink the element count.

Testing

The unit suite can't exercise real CGPath/CAShapeLayer code, so I verified on an iOS 26.5 simulator:

  • Uniform radius + corner-shape: squircle → layer.cornerCurve === 'continuous' (unchanged behavior).
  • Non-uniform radius (20pt top corners) + corner-shape: squircle → layer.mask is a CAShapeLayer whose path containsPoint a coordinate that lies inside a squircle but outside a circular arc of the same radius — confirming the mask is the superellipse, not a circle. A near-vertex control point correctly reports outside.
  • corner-shape unset/round → identical mask path shape as before.

Refs #11365.

corner-shape: squircle maps to CALayer.cornerCurve for views with a
uniform border-radius, but any view with non-uniform radii (e.g. a
bottom sheet with rounded top corners) takes the custom CGPath
generators, which hardcoded circular arcs and ignored cornerShape.

This adds a squircle corner curve — a sampled n=4 superellipse, the
curve the CSS spec defines for corner-shape: squircle and the closest
parametric approximation of Apple's continuous corner — to the
non-uniform outer clip path, the inner border clip path, and the box
shadow paths. Round corners are unchanged.

Corner masks are rasterized once per bounds change, so a 12-step
polyline per quarter arc is visually indistinguishable at screen scale
and keeps the code dependency-free (CAShapeLayer has no cornerCurve
equivalent for custom paths).
aleclarson added a commit to octane-xplat/octane-xplat that referenced this pull request Sep 25, 2026
@NathanWalker NathanWalker changed the title fix(core-ios): honor corner-shape: squircle for non-uniform border radii fix(ios): honor corner-shape: squircle for non-uniform border radii Sep 25, 2026
@nx-cloud

nx-cloud Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit dd4717e

Command Status Duration Result
nx run-many -t build -p core,webpack5,vite ✅ Succeeded 20s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-25 18:19:36 UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nativescript/core@11451
npm i https://pkg.pr.new/@nativescript/vite@11451
npm i https://pkg.pr.new/@nativescript/webpack@11451

commit: dd4717e

@edusperoni

Copy link
Copy Markdown
Contributor

Thanks for taking this on, and apologies up front: the confusion here is mine, not yours.

When I added corner-shape in #11365 I mapped squircle to kCACornerCurveContinuous believing the two are the same curve. They are not. I measured on the simulator against a real CALayer with cornerCurve = .continuous:

  • Apple's continuous corner at radius r is within 0.019 r of a plain circle of the same radius. It is essentially CSS round with the curvature eased in over a 1.528665 r run-up (CALayer.cornerCurveExpansionFactor(.continuous)).
  • CSS squircle (superellipse(2)) at the same radius is 0.187 r away, about 3 pt at r=16. No superellipse(K) at any radius gets closer to continuous than the circle does.
  • CALayerCornerCurve only has circular and continuous, so the CSS squircle can only ever be a mask path on iOS.

Your helper draws the correct CSS superellipse(2) corner: same curve, same corner box, per-axis radii on the inner border, all matching the spec algorithm. What it is not is kCACornerCurveContinuous, which is what squircle currently means on the uniform fast path. Merging now would make the two branches disagree by ~0.19 r, which is worse than the circular fallback we have today.

Plan for 9.2 (details and numbers in #11452): fix the naming instead of building on the wrong mapping.

  • corner-shape: squircle becomes spec-correct superellipse(2), drawn as a mask path on every branch including the uniform one, since Core Animation cannot draw it.
  • Apple's continuous curve moves to an iOS-only property, -ios-corner-shape: continuous, mapping to kCACornerCurveContinuous on the fast path and the equivalent three-cubic path elsewhere.
  • corner-shape: round stays kCACornerCurveCircular, which is exactly the spec.

So I'd like to defer this PR until that lands and then take it as the non-uniform half of the spec squircle work. The generator plumbing (outer clip, inner border, shadow, animation paths) is exactly what we need. One change I'd ask for then: replace the polyline with two cubic Béziers per corner, which is also how Blink renders it.

Two cubics per corner. Fitted to the n=4 superellipse: max deviation 1.65e-4 r versus 1.9e-3 r for the 12-segment polyline, 2 path elements per corner instead of 12, exact tangents at both ends, and a smooth seam on the diagonal. Same signature as your helper, so no call site changes. Expressing points as c + X·d2 + Y·d1 removes the orientation branch and handles the inner border's unequal x/y radii for free (the spec's affine mapping of the unit corner into the startRadius × endRadius box).

// CSS corner-shape: squircle == superellipse(2), i.e. |x|^4 + |y|^4 = r^4.
// Each quarter is two cubic Béziers (one per half-corner, mirrored across the
// diagonal), fitted to within 1.7e-4·r of the exact curve.
const SQUIRCLE_H = 0.840896; // 0.5^(1/4): where the curve crosses the diagonal
const SQUIRCLE_A = 0.38905; // first control point, along the incoming edge
const SQUIRCLE_B = 0.1678; // second control point, offset along the diagonal tangent

function addSquircleArcToPoint(path: any, t1x: number, t1y: number, t2x: number, t2y: number, vx: number, vy: number): void {
	CGPathAddLineToPoint(path, null, t1x, t1y);

	// The corner-box corner opposite the vertex is the curve's center.
	const cx = t1x + t2x - vx;
	const cy = t1y + t2y - vy;
	// d1: center → t1 (length = radius along the incoming edge), d2: center → t2.
	const d1x = t1x - cx, d1y = t1y - cy;
	const d2x = t2x - cx, d2y = t2y - cy;
	const px = (X: number, Y: number) => cx + X * d2x + Y * d1x;
	const py = (X: number, Y: number) => cy + X * d2y + Y * d1y;

	const h = SQUIRCLE_H, a = SQUIRCLE_A, b = SQUIRCLE_B;
	// (0,1) = t1 → (h,h) on the diagonal
	CGPathAddCurveToPoint(path, null, px(a, 1), py(a, 1), px(h - b, h + b), py(h - b, h + b), px(h, h), py(h, h));
	// (h,h) → (1,0) = t2, the first half mirrored across the diagonal
	CGPathAddCurveToPoint(path, null, px(h + b, h - b), py(h + b, h - b), px(1, a), py(1, a), t2x, t2y);
}

The radius capping you have is already right for this shape: unlike continuous, the superellipse consumes exactly r of each edge. And only h, a, b depend on K (h = 0.5^(1/2^K), a/b refitted per K), so a future superellipse(K) value is a constants lookup on the same code.

Sorry again for the detour. Tracking the naming fix in #11452.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants