@octane-xplat/smooth-corners 0.0.1 → 0.11.0

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.
@@ -0,0 +1,67 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useCallback, useEffect, useRef } from 'octane';
3
+ import { isIOS } from '@nativescript/core';
4
+ import { applySmoothClip as applyIosClip, applySmoothShadow as applyIosShadow } from './apply.ios';
5
+ import { applySmoothClip as applyAndroidClip, applySmoothShadow as applyAndroidShadow } from './apply.android';
6
+ import type { SmoothCornersProps } from './props';
7
+
8
+ const applyClip = isIOS ? applyIosClip : applyAndroidClip;
9
+ const applyShadow = isIOS ? applyIosShadow : applyAndroidShadow;
10
+
11
+ /** SmoothCorners — one generated corner curve, clipped natively.
12
+ * Two nested gridlayouts: the inner carries the clip (layer mask on iOS,
13
+ * drawable+outline on Android); the outer carries the iOS drop shadow
14
+ * (a masked layer can't show its own). Radius comes from `corners`, not
15
+ * `border-radius`. */
16
+ export function SmoothCorners(props: SmoothCornersProps) @{
17
+ const state = useRef<{ inner: any; outer: any; props: SmoothCornersProps }>({
18
+ inner: null,
19
+ outer: null,
20
+ props,
21
+ });
22
+
23
+ state.current.props = props;
24
+
25
+ const apply = useCallback(() => {
26
+ const s = state.current;
27
+ if (s.inner) applyClip(s.inner, s.props);
28
+ if (s.outer) applyShadow(s.outer, s.props);
29
+ }, []);
30
+
31
+ const bind = useCallback(
32
+ (which: 'inner' | 'outer') => (view: any) => {
33
+ const s = state.current;
34
+ const prev = s[which];
35
+ if (prev && prev !== view) {
36
+ prev.off?.('loaded', apply);
37
+ prev.off?.('layoutChanged', apply);
38
+ }
39
+
40
+ s[which] = view;
41
+ if (!view) return;
42
+ view.on?.('loaded', apply);
43
+ view.on?.('layoutChanged', apply);
44
+ apply();
45
+ },
46
+ [apply],
47
+ );
48
+
49
+ const bindInner = useRef(bind('inner'));
50
+ const bindOuter = useRef(bind('outer'));
51
+
52
+ // Prop changes without a layout event still need a re-clip.
53
+ useEffect(() => apply());
54
+
55
+ return (
56
+ <gridlayout ref={bindOuter.current}>
57
+ <gridlayout
58
+ ref={bindInner.current}
59
+ id={props.id}
60
+ className={props.className}
61
+ style={props.style}
62
+ >
63
+ {props.children}
64
+ </gridlayout>
65
+ </gridlayout>
66
+ );
67
+ }
@@ -0,0 +1,71 @@
1
+ import { useLayoutEffect, useRef, useState } from 'octane';
2
+ import { smoothPathD } from './path';
3
+ import type { SmoothCornersProps } from './props';
4
+
5
+ /** SmoothCorners — `clip-path: path(d)` on the inner box + an SVG stroke
6
+ * overlay for `border`. `shadow` wraps the box in a `drop-shadow` filter so
7
+ * the shadow follows the clip (spread is not expressible — ignored). */
8
+ export function SmoothCorners(props: SmoothCornersProps) @{
9
+ const ref = useRef<HTMLDivElement | null>(null);
10
+ const [size, setSize] = useState({ w: 0, h: 0 });
11
+
12
+ useLayoutEffect(() => {
13
+ const el = ref.current;
14
+ if (!el) return;
15
+ const update = () => {
16
+ const r = el.getBoundingClientRect();
17
+ setSize((prev) =>
18
+ prev.w === r.width && prev.h === r.height ? prev : { w: r.width, h: r.height },
19
+ );
20
+ };
21
+
22
+ update();
23
+ const ro = new ResizeObserver(update);
24
+ ro.observe(el);
25
+ return () => ro.disconnect();
26
+ }, []);
27
+
28
+ const d = smoothPathD(size.w, size.h, props.corners);
29
+
30
+ const box = (
31
+ <div
32
+ ref={ref}
33
+ id={props.id}
34
+ className={props.className}
35
+ style={{
36
+ position: 'relative',
37
+ ...props.style,
38
+ clipPath: size.w > 0 ? `path("${d}")` : undefined,
39
+ }}
40
+ >
41
+ {props.children}
42
+ {props.border && size.w > 0 && (
43
+ <svg
44
+ style={{
45
+ position: 'absolute',
46
+ inset: 0,
47
+ width: '100%',
48
+ height: '100%',
49
+ pointerEvents: 'none',
50
+ overflow: 'visible',
51
+ }}
52
+ viewBox={`0 0 ${size.w} ${size.h}`}
53
+ preserveAspectRatio="none"
54
+ >
55
+ <path
56
+ d={d}
57
+ fill="none"
58
+ stroke={props.border.color}
59
+ // Stroke is centered on the edge — the clip removes the outer half.
60
+ strokeWidth={props.border.width * 2}
61
+ />
62
+ </svg>
63
+ )}
64
+ </div>
65
+ );
66
+
67
+ const shadow = props.shadow;
68
+ if (!shadow) return box;
69
+ const filter = `drop-shadow(${shadow.offsetX ?? 0}px ${shadow.offsetY ?? 0}px ${shadow.blur ?? 0}px ${shadow.color ?? 'rgba(0,0,0,0.35)'})`;
70
+ return <div style={{ display: 'grid', filter }}>{box}</div>;
71
+ }
@@ -0,0 +1,136 @@
1
+ /** Android smooth-corners apply — custom Drawable paints background +
2
+ * border along the generated path; a ViewOutlineProvider clips children
3
+ * (`setPath` API 30+, `setConvexPath` 21–29 for convex curves); `shadow`
4
+ * maps to `elevation`, which follows the outline. The `android` global is
5
+ * touched only inside functions so the module is import-safe on iOS. */
6
+ import { Color, Utils } from '@nativescript/core'
7
+ import { generateCommands } from './path'
8
+ import type { PathCommand } from './path'
9
+ import type { SmoothCornersProps } from './props'
10
+
11
+ declare const android: any
12
+
13
+ const PATH_KEY = '__smoothPath'
14
+
15
+ function toAndroidPath(cmds: PathCommand[]): any {
16
+ const p = new android.graphics.Path()
17
+ for (const c of cmds) {
18
+ switch (c.c) {
19
+ case 'M':
20
+ p.moveTo(c.x, c.y)
21
+ break
22
+ case 'L':
23
+ p.lineTo(c.x, c.y)
24
+ break
25
+ case 'C':
26
+ p.cubicTo(c.x1, c.y1, c.x2, c.y2, c.x, c.y)
27
+ break
28
+ case 'A': {
29
+ // Canvas is y-down like SVG — degrees map directly.
30
+ const rect = new android.graphics.RectF(c.cx - c.r, c.cy - c.r, c.cx + c.r, c.cy + c.r)
31
+ p.arcTo(rect, c.startDeg, c.sweepDeg, false)
32
+ break
33
+ }
34
+ case 'Z':
35
+ p.close()
36
+ break
37
+ }
38
+ }
39
+
40
+ return p
41
+ }
42
+
43
+ function makeDrawable(state: { view: any; props: SmoothCornersProps; path: any }): any {
44
+ return (android.graphics.drawable.Drawable as any).extend({
45
+ draw(canvas: any) {
46
+ const path = state.path
47
+ if (!path) {
48
+ return
49
+ }
50
+
51
+ const bg = state.view?.backgroundColor ?? state.view?.style?.backgroundColor
52
+ const border = state.props.border
53
+ if (bg) {
54
+ const paint = new android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
55
+ paint.setStyle(android.graphics.Paint.Style.FILL)
56
+ paint.setColor(new Color(bg).android)
57
+ canvas.drawPath(path, paint)
58
+ }
59
+
60
+ if (border && border.width > 0 && border.color) {
61
+ const paint = new android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
62
+ paint.setStyle(android.graphics.Paint.Style.STROKE)
63
+ // Centered on the edge; the outline clip removes the outer half.
64
+ paint.setStrokeWidth(Utils.layout.toDevicePixels(border.width) * 2)
65
+ paint.setColor(new Color(border.color).android)
66
+ canvas.drawPath(path, paint)
67
+ }
68
+ },
69
+ getOpacity() {
70
+ return android.graphics.PixelFormat.TRANSLUCENT
71
+ },
72
+ setAlpha(_a: number) {},
73
+ setColorFilter(_f: any) {},
74
+ })
75
+ }
76
+
77
+ /** Clip the inner box: outline provider + background/border drawable. */
78
+ export function applySmoothClip(view: any, props: SmoothCornersProps): void {
79
+ const s = view?.[PATH_KEY] ?? (view[PATH_KEY] = { view, props, path: null, drawable: null })
80
+ s.props = props
81
+ const v = view?.android as any
82
+ const size = view?.getActualSize?.()
83
+ if (!v || !size || size.width <= 0 || size.height <= 0) {
84
+ return
85
+ }
86
+
87
+ s.path = toAndroidPath(
88
+ generateCommands(
89
+ Utils.layout.toDevicePixels(size.width),
90
+ Utils.layout.toDevicePixels(size.height),
91
+ props.corners,
92
+ ),
93
+ )
94
+
95
+ const path = s.path
96
+ v.setOutlineProvider(
97
+ new ((android.view.ViewOutlineProvider as any).extend({
98
+ getOutline(target: any, outline: any) {
99
+ if (!path) {
100
+ return
101
+ }
102
+
103
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
104
+ outline.setPath(path)
105
+ } else {
106
+ // Concave paths throw — degrade to the rect; the drawable
107
+ // still draws the correct background shape.
108
+ try {
109
+ outline.setConvexPath(path)
110
+ } catch {
111
+ outline.setRect(0, 0, target.getWidth(), target.getHeight())
112
+ }
113
+ }
114
+ },
115
+ }))(),
116
+ )
117
+
118
+ v.setClipToOutline(true)
119
+
120
+ if (!s.drawable) {
121
+ s.drawable = makeDrawable(s)
122
+ v.setBackground(s.drawable)
123
+ }
124
+
125
+ s.drawable.setBounds(0, 0, v.getWidth(), v.getHeight())
126
+ v.invalidate()
127
+
128
+ // elevation shadows follow the outline — the curve, free.
129
+ if (props.shadow) {
130
+ v.setElevation(Utils.layout.toDevicePixels(props.shadow.blur ?? 8))
131
+ }
132
+ }
133
+
134
+ /** Android needs no separate shadow layer — `elevation` follows the inner
135
+ * view's outline directly, so the outer hook is a no-op. */
136
+ export function applySmoothShadow(_view: any, _props: SmoothCornersProps): void {}
@@ -0,0 +1,136 @@
1
+ /** iOS smooth-corners apply — CAShapeLayer mask on the inner view,
2
+ * layer.shadow* on the outer. Native globals resolve lazily inside these
3
+ * functions so the module is import-safe on Android bundles. */
4
+ import { Color } from '@nativescript/core'
5
+ import { generateCommands } from './path'
6
+ import type { PathCommand } from './path'
7
+ import type { SmoothCornersProps } from './props'
8
+
9
+ declare const CGPathCreateMutable: any
10
+ declare const CGPathMoveToPoint: any
11
+ declare const CGPathAddLineToPoint: any
12
+ declare const CGPathAddCurveToPoint: any
13
+ declare const CGPathAddArc: any
14
+ declare const CGPathCloseSubpath: any
15
+ declare const CAShapeLayer: any
16
+
17
+ const rad = (deg: number) => (deg * Math.PI) / 180
18
+
19
+ function toCGPath(cmds: PathCommand[]): any {
20
+ const p = CGPathCreateMutable()
21
+ for (const c of cmds) {
22
+ switch (c.c) {
23
+ case 'M':
24
+ CGPathMoveToPoint(p, null, c.x, c.y)
25
+ break
26
+ case 'L':
27
+ CGPathAddLineToPoint(p, null, c.x, c.y)
28
+ break
29
+ case 'C':
30
+ CGPathAddCurveToPoint(p, null, c.x1, c.y1, c.x2, c.y2, c.x, c.y)
31
+ break
32
+ case 'A':
33
+ // y-down convention matches CGPath on iOS: positive sweep =
34
+ // increasing angle. Explicit end angle; the flag picks direction.
35
+ CGPathAddArc(
36
+ p,
37
+ null,
38
+ c.cx,
39
+ c.cy,
40
+ c.r,
41
+ rad(c.startDeg),
42
+ rad(c.startDeg + c.sweepDeg),
43
+ c.sweepDeg < 0,
44
+ )
45
+
46
+ break
47
+ case 'Z':
48
+ CGPathCloseSubpath(p)
49
+ break
50
+ }
51
+ }
52
+
53
+ return p
54
+ }
55
+
56
+ const MASK_KEY = '__smoothMask'
57
+ const BORDER_KEY = '__smoothBorder'
58
+
59
+ function sizeOf(view: any): { w: number; h: number } | null {
60
+ const size = view?.getActualSize?.()
61
+ return size && size.width > 0 && size.height > 0 ? { w: size.width, h: size.height } : null
62
+ }
63
+
64
+ /** Clip the inner box: layer mask + optional border stroke layer. */
65
+ export function applySmoothClip(view: any, props: SmoothCornersProps): void {
66
+ const v = view?.ios
67
+ const size = v && sizeOf(view)
68
+ if (!v || !size) {
69
+ return
70
+ }
71
+
72
+ const path = toCGPath(generateCommands(size.w, size.h, props.corners))
73
+
74
+ let mask = v[MASK_KEY] as any
75
+ if (!mask) {
76
+ mask = CAShapeLayer.new()
77
+ v[MASK_KEY] = mask
78
+ }
79
+
80
+ mask.path = path
81
+ v.layer.mask = mask
82
+
83
+ let borderLayer = v[BORDER_KEY] as any
84
+ const border = props.border
85
+ if (border && border.width > 0 && border.color) {
86
+ if (!borderLayer) {
87
+ borderLayer = CAShapeLayer.new()
88
+ v[BORDER_KEY] = borderLayer
89
+ v.layer.addSublayer(borderLayer)
90
+ }
91
+
92
+ borderLayer.frame = v.layer.bounds
93
+ borderLayer.path = path
94
+ borderLayer.fillColor = null
95
+ borderLayer.strokeColor = new Color(border.color).ios.CGColor
96
+ // Stroke centered on the edge; the mask clips the outer half.
97
+ borderLayer.lineWidth = border.width * 2
98
+ } else if (borderLayer) {
99
+ borderLayer.removeFromSuperlayer()
100
+ v[BORDER_KEY] = null
101
+ }
102
+ }
103
+
104
+ /** Drop shadow on the outer view (a masked layer can't show its own). */
105
+ export function applySmoothShadow(view: any, props: SmoothCornersProps): void {
106
+ const layer = view?.ios?.layer
107
+ if (!layer) {
108
+ return
109
+ }
110
+
111
+ const shadow = props.shadow
112
+ if (!shadow) {
113
+ layer.shadowPath = null
114
+ layer.shadowOpacity = 0
115
+ return
116
+ }
117
+
118
+ const size = sizeOf(view)
119
+ if (!size) {
120
+ return
121
+ }
122
+
123
+ // Spread grows the outline — regenerate larger, recenter via the offset.
124
+ const spread = shadow.spread ?? 0
125
+ const w = size.w + spread * 2
126
+ const h = size.h + spread * 2
127
+ layer.shadowPath = toCGPath(generateCommands(w, h, props.corners))
128
+ layer.shadowOffset = {
129
+ width: (shadow.offsetX ?? 0) - spread,
130
+ height: (shadow.offsetY ?? 0) - spread,
131
+ }
132
+
133
+ layer.shadowColor = new Color(shadow.color ?? '#000000').ios.CGColor
134
+ layer.shadowOpacity = shadow.opacity ?? 0.35
135
+ layer.shadowRadius = shadow.blur ?? 0
136
+ }
@@ -0,0 +1,10 @@
1
+ export { SmoothCorners } from './SmoothCorners.macos.tsrx'
2
+ export type {
3
+ CornerConfig,
4
+ CornerCurve,
5
+ CornerOptions,
6
+ PerCornerConfig,
7
+ SmoothBorderConfig,
8
+ SmoothCornersProps,
9
+ SmoothShadowConfig,
10
+ } from './props'
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Platform barrel — extensionless specifier resolves the per-OS leaf
2
+ // (.ios.tsrx/.android.tsrx under native, .macos.tsrx under macos,
3
+ // .web.tsrx under web); same convention as @octane-xplat/ui index.shared.
4
+ export { SmoothCorners } from './SmoothCorners'
5
+ export type {
6
+ CornerConfig,
7
+ CornerCurve,
8
+ CornerOptions,
9
+ PerCornerConfig,
10
+ SmoothBorderConfig,
11
+ SmoothCornersProps,
12
+ SmoothShadowConfig,
13
+ } from './props'
@@ -0,0 +1,10 @@
1
+ export { SmoothCorners } from './SmoothCorners.web.tsrx'
2
+ export type {
3
+ CornerConfig,
4
+ CornerCurve,
5
+ CornerOptions,
6
+ PerCornerConfig,
7
+ SmoothBorderConfig,
8
+ SmoothCornersProps,
9
+ SmoothShadowConfig,
10
+ } from './props'