diff --git a/packages/core/core-types/index.ts b/packages/core/core-types/index.ts index df7c173603..3f372a49fe 100644 --- a/packages/core/core-types/index.ts +++ b/packages/core/core-types/index.ts @@ -280,6 +280,13 @@ export namespace CoreTypes { export const parse = makeParser(isValid); } + export type IOSCornerShapeType = CornerShapeType | 'continuous'; + export namespace IOSCornerShape { + export const continuous: IOSCornerShapeType = 'continuous'; + export const isValid = makeValidator(CornerShape.round, CornerShape.squircle, continuous); + export const parse = makeParser(isValid); + } + export namespace AnimationCurve { export const ease = 'ease'; export const easeIn = 'easeIn'; diff --git a/packages/core/ui/styling/background-common.ts b/packages/core/ui/styling/background-common.ts index b102c795e3..e24a0cd82b 100644 --- a/packages/core/ui/styling/background-common.ts +++ b/packages/core/ui/styling/background-common.ts @@ -39,7 +39,7 @@ export class Background { public borderTopRightRadius = 0; public borderBottomLeftRadius = 0; public borderBottomRightRadius = 0; - public cornerShape: CoreTypes.CornerShapeType = CoreTypes.CornerShape.round; + public cornerShape: CoreTypes.IOSCornerShapeType = CoreTypes.CornerShape.round; public clipPath: string | ClipPathFunction; public boxShadows: BoxShadow[]; public clearFlags: number = BackgroundClearFlags.NONE; @@ -194,7 +194,7 @@ export class Background { return clone; } - public withCornerShape(value: CoreTypes.CornerShapeType): Background { + public withCornerShape(value: CoreTypes.IOSCornerShapeType): Background { const clone = this.clone(); clone.cornerShape = value; diff --git a/packages/core/ui/styling/background.d.ts b/packages/core/ui/styling/background.d.ts index 077f9fe911..dbd87175f3 100644 --- a/packages/core/ui/styling/background.d.ts +++ b/packages/core/ui/styling/background.d.ts @@ -80,6 +80,13 @@ export namespace ios { export function createUIImageFromURI(view: View, imageURI: string, flip: boolean, callback: (image: any) => void): void; export function generateClipPath(view: View, bounds: any /* CGRect */): any; export function generateShadowLayerPaths(view: View, bounds: any /* CGRect */): { maskPath: any; shadowPath: any }; + /** + * Whether the layer's own cornerRadius and cornerCurve can draw the corners. + * The CSS squircle has no CALayer curve, so it always goes through a path mask. + * @param background The view's background. + * @returns True when no path mask is needed for the corners. + */ + export function drawsCornersNatively(background: BackgroundDefinition): boolean; export function getUniformBorderRadius(view: View, bounds: any /* CGRect */): number; export function generateNonUniformBorderInnerClipRoundedPath(view: View, bounds: any /* CGRect */): any; export function generateNonUniformBorderOuterClipRoundedPath(view: View, bounds: any /* CGRect */): any; diff --git a/packages/core/ui/styling/background.ios.ts b/packages/core/ui/styling/background.ios.ts index 9da10fff49..63e8267f85 100644 --- a/packages/core/ui/styling/background.ios.ts +++ b/packages/core/ui/styling/background.ios.ts @@ -89,12 +89,12 @@ export namespace ios { // Initialize clipping mask (usually for clip-path and non-uniform rounded borders) maskLayerIfNeeded(nativeView, background); - if (background.hasUniformBorder()) { + if (drawsCornersNatively(background)) { const borderColor = background.getUniformBorderColor(); layer.borderColor = borderColor?.ios?.CGColor; layer.borderWidth = layout.toDeviceIndependentPixels(background.getUniformBorderWidth()); layer.cornerRadius = getUniformBorderRadius(view, layer.bounds); - layer.cornerCurve = background.cornerShape === CoreTypes.CornerShape.squircle ? kCACornerCurveContinuous : kCACornerCurveCircular; + layer.cornerCurve = background.cornerShape === CoreTypes.IOSCornerShape.continuous ? kCACornerCurveContinuous : kCACornerCurveCircular; } else { drawNonUniformBorders(nativeView, background); needsLayerAdjustmentOnScroll = true; @@ -128,7 +128,7 @@ export namespace ios { let needsMask; switch (nativeView.maskType) { case iosViewUtils.LayerMask.BORDER: - needsMask = !background.hasUniformBorder() && background.hasBorderRadius(); + needsMask = !drawsCornersNatively(background) && background.hasBorderRadius(); break; case iosViewUtils.LayerMask.CLIP_PATH: needsMask = !!background.clipPath; @@ -153,7 +153,7 @@ export namespace ios { clearNonUniformColorBorders(nativeView); } - if (background.hasUniformBorder()) { + if (drawsCornersNatively(background)) { clearNonUniformBorders(nativeView); } } @@ -232,7 +232,7 @@ export namespace ios { // Generate more detailed paths if view has border radius if (background.hasBorderRadius()) { - if (background.hasUniformBorder()) { + if (drawsCornersNatively(background)) { const cornerRadius = layer.cornerRadius; const cappedRadius = getBorderCapRadius(cornerRadius, width / 2, height / 2); const cappedOuterRadii: CappedOuterRadii = { @@ -333,6 +333,16 @@ export namespace ios { return path; } + /** + * Whether the layer's own cornerRadius and cornerCurve can draw the corners. + * The CSS squircle has no CALayer curve, so it always goes through a path mask. + * @param background The view's background. + * @returns True when no path mask is needed for the corners. + */ + export function drawsCornersNatively(background: BackgroundDefinition): boolean { + return background.hasUniformBorder() && background.cornerShape !== CoreTypes.CornerShape.squircle; + } + export function getUniformBorderRadius(view: View, bounds: CGRect): number { const background = view.style.backgroundInternal; const { width, height } = bounds.size; @@ -370,7 +380,7 @@ function maskLayerIfNeeded(nativeView: NativeScriptUIView, background: Backgroun // Since layers can only accept up to a single mask at a time, clip path is given more priority if (background.clipPath) { nativeView.maskType = iosViewUtils.LayerMask.CLIP_PATH; - } else if (!background.hasUniformBorder() && background.hasBorderRadius()) { + } else if (!ios.drawsCornersNatively(background) && background.hasBorderRadius()) { nativeView.maskType = iosViewUtils.LayerMask.BORDER; } else { nativeView.maskType = null; diff --git a/packages/core/ui/styling/style-properties.spec.ts b/packages/core/ui/styling/style-properties.spec.ts index 56444f81e0..73f6e99e73 100644 --- a/packages/core/ui/styling/style-properties.spec.ts +++ b/packages/core/ui/styling/style-properties.spec.ts @@ -1,8 +1,29 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Label } from '../label'; import { Color } from '../../color'; +function onAndroid(): void { + let previousApple: boolean; + let previousIOS: boolean; + let previousAndroid: boolean; + + beforeEach(() => { + previousApple = (globalThis as any).__APPLE__; + previousIOS = (globalThis as any).__IOS__; + previousAndroid = (globalThis as any).__ANDROID__; + (globalThis as any).__APPLE__ = false; + (globalThis as any).__IOS__ = false; + (globalThis as any).__ANDROID__ = true; + }); + + afterEach(() => { + (globalThis as any).__APPLE__ = previousApple; + (globalThis as any).__IOS__ = previousIOS; + (globalThis as any).__ANDROID__ = previousAndroid; + }); +} + describe('border-color shorthand', () => { it('applies a function color with nested arguments to all sides', () => { const label = new Label(); @@ -60,4 +81,49 @@ describe('corner-shape', () => { expect(() => (label.style.cornerShape = 'bevel' as any)).toThrow(); }); + + it('accepts continuous on Apple platforms', () => { + const label = new Label(); + label.style.cornerShape = 'continuous'; + + expect(label.style.backgroundInternal.cornerShape).toBe('continuous'); + }); +}); + +describe('-ios-corner-shape', () => { + it('sets corner-shape and reads it back', () => { + const label = new Label(); + label.style.iosCornerShape = 'continuous'; + + expect(label.style.cornerShape).toBe('continuous'); + expect(label.style.iosCornerShape).toBe('continuous'); + expect(label.style.backgroundInternal.cornerShape).toBe('continuous'); + }); + + it('rejects unknown keywords', () => { + const label = new Label(); + + expect(() => (label.style.iosCornerShape = 'bevel' as any)).toThrow(); + }); +}); + +describe('corner-shape on Android', () => { + onAndroid(); + + it('keeps the spec grammar', () => { + const label = new Label(); + + expect(() => (label.style.cornerShape = 'continuous')).toThrow(); + label.style.cornerShape = 'squircle'; + expect(label.style.backgroundInternal.cornerShape).toBe('squircle'); + }); + + it('ignores -ios-corner-shape', () => { + const label = new Label(); + label.style.iosCornerShape = 'continuous'; + + expect(label.style.cornerShape).toBe('round'); + expect(label.style.iosCornerShape).toBe('round'); + expect(label.style.backgroundInternal.cornerShape).toBe('round'); + }); }); diff --git a/packages/core/ui/styling/style-properties.ts b/packages/core/ui/styling/style-properties.ts index 62aeebc77a..28a9e970e4 100644 --- a/packages/core/ui/styling/style-properties.ts +++ b/packages/core/ui/styling/style-properties.ts @@ -1053,17 +1053,35 @@ export const borderBottomLeftRadiusProperty = new CssProperty({ +export const cornerShapeProperty = new CssProperty({ name: 'cornerShape', cssName: 'corner-shape', defaultValue: CoreTypes.CornerShape.round, - valueConverter: CoreTypes.CornerShape.parse, + // Only CALayer can draw `continuous`; other platforms keep the spec grammar. + valueConverter: (value) => (__APPLE__ ? CoreTypes.IOSCornerShape.parse(value) : CoreTypes.CornerShape.parse(value)), valueChanged: (target, oldValue, newValue) => { target.backgroundInternal = target.backgroundInternal.withCornerShape(newValue); }, }); cornerShapeProperty.register(Style); +export const iosCornerShapeProperty = new ShorthandProperty({ + name: 'iosCornerShape', + cssName: '-ios-corner-shape', + getter: function (this: Style) { + return this.cornerShape; + }, + // Alias of corner-shape: cascades in its place on Apple, dropped elsewhere. + converter: function (value) { + if (!__APPLE__) { + return []; + } + + return [[cornerShapeProperty, typeof value === 'string' ? CoreTypes.IOSCornerShape.parse(value) : value]]; + }, +}); +iosCornerShapeProperty.register(Style); + const boxShadowProperty = new CssProperty({ name: 'boxShadow', cssName: 'box-shadow', diff --git a/packages/core/ui/styling/style-scope.spec.ts b/packages/core/ui/styling/style-scope.spec.ts index 42115a463c..69c53c3116 100644 --- a/packages/core/ui/styling/style-scope.spec.ts +++ b/packages/core/ui/styling/style-scope.spec.ts @@ -1,9 +1,30 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import { StyleScope, applyInlineStyle, addTaggedAdditionalCSS, removeTaggedAdditionalCSS } from './style-scope'; import { StackLayout } from '../layouts/stack-layout'; import { Label } from '../label'; +function onAndroid(): void { + let previousApple: boolean; + let previousIOS: boolean; + let previousAndroid: boolean; + + beforeEach(() => { + previousApple = (globalThis as any).__APPLE__; + previousIOS = (globalThis as any).__IOS__; + previousAndroid = (globalThis as any).__ANDROID__; + (globalThis as any).__APPLE__ = false; + (globalThis as any).__IOS__ = false; + (globalThis as any).__ANDROID__ = true; + }); + + afterEach(() => { + (globalThis as any).__APPLE__ = previousApple; + (globalThis as any).__IOS__ = previousIOS; + (globalThis as any).__ANDROID__ = previousAndroid; + }); +} + /** * Counts how many times css applies a value to a style property, by wrapping the * `css:` accessor the css state writes through. @@ -43,6 +64,73 @@ function styled(css: string, className?: string): { view: any; scope: StyleScope return { view, scope }; } +describe('-ios-corner-shape alias', () => { + it('cascades in place of corner-shape, so the later declaration wins', () => { + const { view } = styled('label { corner-shape: squircle; -ios-corner-shape: continuous; }'); + view._cssState.onLoaded(); + + expect(view.style.cornerShape).toBe('continuous'); + }); + + it('loses to a later corner-shape declaration', () => { + const { view } = styled('label { -ios-corner-shape: continuous; corner-shape: squircle; }'); + view._cssState.onLoaded(); + + expect(view.style.cornerShape).toBe('squircle'); + }); + + it('follows specificity like any other declaration', () => { + const { view } = styled('label.card { corner-shape: squircle; } label { -ios-corner-shape: continuous; }'); + view.className = 'card'; + view._cssState.onLoaded(); + + expect(view.style.cornerShape).toBe('squircle'); + }); + + it('is unset when its rule stops matching', () => { + const { view } = styled('label:highlighted { -ios-corner-shape: continuous; }'); + view._cssState.onLoaded(); + + view.addPseudoClass('highlighted'); + expect(view.style.cornerShape).toBe('continuous'); + + view.deletePseudoClass('highlighted'); + expect(view.style.cornerShape).toBe('round'); + }); + + it('applies from an inline style', () => { + const view = new Label(); + applyInlineStyle(view, '-ios-corner-shape: continuous'); + + expect(view.style.cornerShape).toBe('continuous'); + }); +}); + +describe('-ios-corner-shape alias on Android', () => { + onAndroid(); + + it('is dropped from the cascade', () => { + const { view } = styled('label { corner-shape: squircle; -ios-corner-shape: continuous; }'); + view._cssState.onLoaded(); + + expect(view.style.cornerShape).toBe('squircle'); + }); + + it('leaves corner-shape at its default when declared alone', () => { + const { view } = styled('label { -ios-corner-shape: continuous; }'); + view._cssState.onLoaded(); + + expect(view.style.cornerShape).toBe('round'); + }); + + it('is ignored in an inline style', () => { + const view = new Label(); + applyInlineStyle(view, '-ios-corner-shape: continuous'); + + expect(view.style.cornerShape).toBe('round'); + }); +}); + describe('CssState.setPropertyValues', () => { it('applies matched declarations', () => { const { view } = styled('label { color: red; }'); diff --git a/packages/core/ui/styling/style/index.ts b/packages/core/ui/styling/style/index.ts index 209e8faf9d..d025511a0a 100644 --- a/packages/core/ui/styling/style/index.ts +++ b/packages/core/ui/styling/style/index.ts @@ -154,10 +154,19 @@ export class Style extends Observable { /** * The curve used to round the corners: 'round' (circular arcs, the default) - * or 'squircle' (Apple's continuous corner curve). iOS only; Android always - * renders circular corners. + * or 'squircle' (the CSS superellipse). On Apple platforms it also accepts + * 'continuous' (Apple's continuous corner curve), normally set through + * iosCornerShape. Android always renders circular corners. */ - public cornerShape: CoreTypes.CornerShapeType; + public cornerShape: CoreTypes.IOSCornerShapeType; + + /** + * Apple-only alias of cornerShape (`-ios-corner-shape` in CSS). It cascades + * in place of cornerShape on Apple platforms and is ignored elsewhere, so + * `corner-shape: squircle; -ios-corner-shape: continuous;` renders the + * continuous curve on iOS and the squircle on Android. + */ + public iosCornerShape: CoreTypes.IOSCornerShapeType; public boxShadow: string | ShadowCSSValues[];