diff --git a/packages/core/ui/core/view/index.android.ts b/packages/core/ui/core/view/index.android.ts index fea1f8e3c8..bf4714f477 100644 --- a/packages/core/ui/core/view/index.android.ts +++ b/packages/core/ui/core/view/index.android.ts @@ -9,7 +9,7 @@ import { ShowModalOptions, hiddenProperty } from '../view-base'; import { isCssWideKeyword } from '../properties/property-shared'; import { EventData } from '../../../data/observable'; -import { perspectiveProperty, visibilityProperty, opacityProperty, horizontalAlignmentProperty, verticalAlignmentProperty, minWidthProperty, minHeightProperty, widthProperty, heightProperty, marginLeftProperty, marginTopProperty, marginRightProperty, marginBottomProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, androidElevationProperty, androidDynamicElevationOffsetProperty } from '../../styling/style-properties'; +import { perspectiveProperty, visibilityProperty, opacityProperty, horizontalAlignmentProperty, verticalAlignmentProperty, minWidthProperty, minHeightProperty, widthProperty, heightProperty, marginLeftProperty, marginTopProperty, marginRightProperty, marginBottomProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, androidElevationProperty, androidDynamicElevationOffsetProperty, filterProperty } from '../../styling/style-properties'; import { CoreTypes } from '../../../core-types'; import { Background, BackgroundClearFlags, refreshBorderDrawable } from '../../styling/background'; @@ -24,6 +24,7 @@ import { AccessibilityLiveRegion, AccessibilityRole, AndroidAccessibilityEvent, import * as Utils from '../../../utils'; import { SDK_VERSION } from '../../../utils/constants'; import { BoxShadow } from '../../styling/box-shadow'; +import { FilterFunction } from '../../styling/filter-parser'; import { NativeScriptAndroidView } from '../../utils'; export * from './view-common'; @@ -1580,6 +1581,114 @@ export class View extends ViewCommon { org.nativescript.widgets.ViewHelper.setZIndex(this.nativeViewProtected, value); } + [filterProperty.getDefault](): FilterFunction[] { + return []; + } + + [filterProperty.setNative](value: FilterFunction[]) { + const nativeView = this.nativeViewProtected; + if (!value || value.length === 0) { + nativeView.setRenderEffect(null); + return; + } + + let effect: any = null; + for (const filter of value) { + effect = this.applyFilterToRenderEffect(effect, filter); + } + if (effect) { + nativeView.setRenderEffect(effect); + } + } + + private applyFilterToRenderEffect(effect: android.graphics.RenderEffect | null, filter: FilterFunction): android.graphics.RenderEffect | null { + const type = filter.type; + const val = filter.value; + + // Use RenderEffect for blur and color filters on API 31+ + if (android.os.Build.VERSION.SDK_INT < 31) { + // Not supported on older Android versions + return effect; + } + + if (type === 'blur') { + const blurEffect = android.graphics.RenderEffect.createBlurEffect(val, val, android.graphics.Shader.TileMode.CLAMP); + if (effect) { + return android.graphics.RenderEffect.createChainEffect(effect, blurEffect); + } + return blurEffect; + } + + // For color filters, we need to create a ColorMatrix and then a ColorFilterEffect + if (['brightness', 'contrast', 'saturate', 'grayscale', 'invert', 'sepia', 'hue-rotate'].includes(type)) { + const colorMatrix = this.getColorMatrixForFilter(type, val); + if (colorMatrix) { + const colorFilterEffect = android.graphics.RenderEffect.createColorFilterEffect(new android.graphics.ColorMatrixColorFilter(colorMatrix)); + if (effect) { + return android.graphics.RenderEffect.createChainEffect(effect, colorFilterEffect); + } + return colorFilterEffect; + } + } + + // drop-shadow not implemented on Android (would require custom handling) + // opacity handled by opacity property + + return effect; + } + + private getColorMatrixForFilter(type: string, val: number): android.graphics.ColorMatrix | null { + const cm = new android.graphics.ColorMatrix(); + const a = val; // for percentages, val is fraction + + switch (type) { + case 'brightness': + // Brightness adjustment: multiply RGB by brightness factor + cm.setScale(a, a, a, 1); + break; + case 'contrast': + // Contrast: scale around 0.5 + const scaleContrast = a; + const translateContrast = 0.5 * (1 - scaleContrast); + cm.set(scaleContrast, 0, 0, 0, translateContrast * 255, + 0, scaleContrast, 0, 0, translateContrast * 255, + 0, 0, scaleContrast, 0, translateContrast * 255, + 0, 0, 0, 1, 0); + break; + case 'saturate': + // Saturation: use ColorMatrix.setSaturation + cm.setSaturation(a); + break; + case 'grayscale': + // Grayscale: set saturation to 0 + cm.setSaturation(0); + break; + case 'invert': + // Invert: -1 scale and +1 translate + cm.set(-1, 0, 0, 0, 255, + 0, -1, 0, 0, 255, + 0, 0, -1, 0, 255, + 0, 0, 0, 1, 0); + break; + case 'sepia': + // Sepia tone matrix + cm.set(0.393 + 0.607 * (1 - a), 0.769 - 0.769 * (1 - a), 0.189 - 0.189 * (1 - a), 0, 0, + 0.349 - 0.349 * (1 - a), 0.686 + 0.314 * (1 - a), 0.168 - 0.168 * (1 - a), 0, 0, + 0.272 - 0.272 * (1 - a), 0.534 - 0.534 * (1 - a), 0.131 + 0.869 * (1 - a), 0, 0, + 0, 0, 0, 1, 0); + break; + case 'hue-rotate': + // Hue rotation: convert angle to radians and set rotation on color matrix + // ColorMatrix has setRotate function for hue? Actually there is setRotate(axis, angle) for 3D? We can use ColorMatrix.setRotate(android.graphics.ColorMatrix.AXIS_X, angle)? Not exactly. + // There's ColorMatrix.setRotate for R, G, B axes. For hue rotation, we need to rotate in RGB space. There's no direct method. We can use a precomputed matrix. + // For simplicity, skip hue-rotate on Android for now. + return null; + default: + return null; + } + return cm; + } + [backgroundInternalProperty.getDefault](): android.graphics.drawable.Drawable { const nativeView = this.nativeViewProtected; return AndroidHelper.getCopyOrDrawable(nativeView.getBackground(), nativeView.getResources()); diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts index 85b4c8baaf..2771e03077 100644 --- a/packages/core/ui/core/view/index.ios.ts +++ b/packages/core/ui/core/view/index.ios.ts @@ -8,7 +8,7 @@ import { layout, ios as iosUtils, getWindow } from '../../../utils'; import { SDK_VERSION, supportsGlass } from '../../../utils/constants'; import { IOSHelper } from './view-helper'; import { ios as iosBackground, Background } from '../../styling/background'; -import { perspectiveProperty, visibilityProperty, opacityProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, directionProperty } from '../../styling/style-properties'; +import { perspectiveProperty, visibilityProperty, opacityProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, directionProperty, filterProperty } from '../../styling/style-properties'; import { profile } from '../../../profiling'; import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityValueProperty, accessibilityIgnoresInvertColorsProperty } from '../../../accessibility/accessibility-properties'; import { IOSPostAccessibilityNotificationType, AccessibilityEventOptions, AccessibilityRole, AccessibilityState } from '../../../accessibility'; @@ -17,6 +17,7 @@ import type { ModalTransition } from '../../transition/modal-transition'; import { SharedTransition } from '../../transition/shared-transition'; import { NativeScriptUIView } from '../../utils'; import { Color } from '../../../color'; +import { FilterFunction, DropShadowValue } from '../../styling/filter-parser'; export * from './view-common'; export * from './view-helper'; @@ -903,6 +904,113 @@ export class View extends ViewCommon { } } + [filterProperty.getDefault](): FilterFunction[] { + return []; + } + + [filterProperty.setNative](value: FilterFunction[]) { + const nativeView: NativeScriptUIView = this.nativeViewProtected; + if (!value || value.length === 0) { + nativeView.layer.filters = null; + // Also clear drop-shadow if any + nativeView.layer.shadowColor = null; + nativeView.layer.shadowOffset = CGSizeMake(0, 0); + nativeView.layer.shadowRadius = 0; + nativeView.layer.shadowOpacity = 0; + return; + } + + const filters: CIFilter[] = []; + for (const filter of value) { + const type = filter.type; + const val = filter.value; + + switch (type) { + case 'drop-shadow': + // Apply directly to layer + this.applyDropShadowToLayer(nativeView.layer, val); + break; + case 'opacity': + // Set alpha directly (note: may conflict with opacity property) + nativeView.alpha = val; + break; + default: + const ciFilter = this.createCIFilter(filter); + if (ciFilter) { + filters.push(ciFilter); + } + } + } + nativeView.layer.filters = filters; + } + + private createCIFilter(filter: FilterFunction): CIFilter | null { + const type = filter.type; + const val = filter.value; + let ciFilter: CIFilter | null = null; + + switch (type) { + case 'blur': + ciFilter = CIFilter.filterWithName('CIGaussianBlur'); + ciFilter.setValue(val, 'inputRadius'); + break; + case 'brightness': + ciFilter = CIFilter.filterWithName('CIColorControls'); + ciFilter.setValue(val, 'inputBrightness'); + break; + case 'contrast': + ciFilter = CIFilter.filterWithName('CIColorControls'); + ciFilter.setValue(val, 'inputContrast'); + break; + case 'saturate': + ciFilter = CIFilter.filterWithName('CIColorControls'); + ciFilter.setValue(val, 'inputSaturation'); + break; + case 'grayscale': + ciFilter = CIFilter.filterWithName('CIColorControls'); + const saturation = 1 - val; + ciFilter.setValue(saturation, 'inputSaturation'); + break; + case 'invert': + ciFilter = CIFilter.filterWithName('CIColorInvert'); + break; + case 'sepia': + ciFilter = CIFilter.filterWithName('CISepiaTone'); + ciFilter.setValue(val, 'inputIntensity'); + break; + case 'hue-rotate': + ciFilter = CIFilter.filterWithName('CIHueAdjust'); + // Convert degrees to radians + const radians = val * Math.PI / 180; + ciFilter.setValue(radians, 'inputAngle'); + break; + default: + break; + } + return ciFilter; + } + + private applyDropShadowToLayer(layer: CALayer, params: DropShadowValue) { + if (!params) return; + const { h, v, blur = 0, color } = params; + if (color) { + const c = new Color(color); + // @ts-ignore + layer.shadowColor = c.ios.CGColor; + } else { + layer.shadowColor = UIColor.blackColor.CGColor; + } + layer.shadowOffset = CGSizeMake(h, v); + layer.shadowRadius = blur; + if (color) { + const c = new Color(color); + layer.shadowOpacity = c.alpha; + } else { + layer.shadowOpacity = 1; + } + layer.masksToBounds = false; + } + [backgroundInternalProperty.getDefault](): UIColor { return this.nativeViewProtected.backgroundColor; } diff --git a/packages/core/ui/styling/filter-parser.spec.ts b/packages/core/ui/styling/filter-parser.spec.ts new file mode 100644 index 0000000000..9e3a353374 --- /dev/null +++ b/packages/core/ui/styling/filter-parser.spec.ts @@ -0,0 +1,100 @@ +import { parseFilter, FilterFunction } from './filter-parser'; +import { Color } from '../../color'; + +describe('parseFilter', () => { + it('should return empty array for none', () => { + expect(parseFilter('none')).toEqual([]); + expect(parseFilter('')).toEqual([]); + }); + + it('should parse blur', () => { + const result = parseFilter('blur(5px)'); + expect(result.length).toBe(1); + expect(result[0].type).toBe('blur'); + expect(result[0].value).toBe(5); + }); + + it('should parse brightness', () => { + const result = parseFilter('brightness(150%)'); + expect(result.length).toBe(1); + expect(result[0].type).toBe('brightness'); + expect(result[0].value).toBe(1.5); + }); + + it('should parse contrast', () => { + const result = parseFilter('contrast(200%)'); + expect(result[0].type).toBe('contrast'); + expect(result[0].value).toBe(2); + }); + + it('should parse grayscale', () => { + const result = parseFilter('grayscale(100%)'); + expect(result[0].type).toBe('grayscale'); + expect(result[0].value).toBe(1); + }); + + it('should parse saturate', () => { + const result = parseFilter('saturate(50%)'); + expect(result[0].type).toBe('saturate'); + expect(result[0].value).toBe(0.5); + }); + + it('should parse invert', () => { + const result = parseFilter('invert(100%)'); + expect(result[0].type).toBe('invert'); + expect(result[0].value).toBe(1); + }); + + it('should parse sepia', () => { + const result = parseFilter('sepia(100%)'); + expect(result[0].type).toBe('sepia'); + expect(result[0].value).toBe(1); + }); + + it('should parse hue-rotate', () => { + const result = parseFilter('hue-rotate(90deg)'); + expect(result[0].type).toBe('hue-rotate'); + expect(result[0].value).toBe(90); + }); + + it('should parse hue-rotate in radians', () => { + const result = parseFilter('hue-rotate(0.5rad)'); + expect(result[0].type).toBe('hue-rotate'); + // 0.5 rad ≈ 28.65 deg + expect(result[0].value).toBeCloseTo(28.64788975654116, 5); + }); + + it('should parse drop-shadow with lengths and optional color', () => { + const result = parseFilter('drop-shadow(10px 20px 5px black)'); + expect(result.length).toBe(1); + expect(result[0].type).toBe('drop-shadow'); + const ds = result[0].value as any; + expect(ds.h).toBe(10); + expect(ds.v).toBe(20); + expect(ds.blur).toBe(5); + expect(ds.color).toBe('black'); + }); + + it('should parse drop-shadow without blur', () => { + const result = parseFilter('drop-shadow(2px 4px red)'); + const ds = result[0].value as any; + expect(ds.h).toBe(2); + expect(ds.v).toBe(4); + expect(ds.blur).toBe(0); + expect(ds.color).toBe('red'); + }); + + it('should parse multiple filters', () => { + const result = parseFilter('blur(5px) brightness(150%) contrast(200%)'); + expect(result.length).toBe(3); + expect(result[0].type).toBe('blur'); + expect(result[1].type).toBe('brightness'); + expect(result[2].type).toBe('contrast'); + }); + + it('should ignore unknown filter functions', () => { + const result = parseFilter('unknown(5px) blur(3px)'); + expect(result.length).toBe(1); + expect(result[0].type).toBe('blur'); + }); +}); diff --git a/packages/core/ui/styling/filter-parser.ts b/packages/core/ui/styling/filter-parser.ts new file mode 100644 index 0000000000..2b4f26ff4b --- /dev/null +++ b/packages/core/ui/styling/filter-parser.ts @@ -0,0 +1,172 @@ +import { Color } from '../../color'; + +export interface FilterFunction { + type: 'blur' | 'brightness' | 'contrast' | 'drop-shadow' | 'grayscale' | 'hue-rotate' | 'invert' | 'opacity' | 'saturate' | 'sepia'; + value: number | DropShadowValue; +} + +export interface DropShadowValue { + h: number; + v: number; + blur?: number; + color?: string; // CSS color string +} + +/** + * Parses a CSS filter value into an array of FilterFunction. + * Supports: blur, brightness, contrast, drop-shadow, grayscale, hue-rotate, invert, opacity, saturate, sepia. + * @param value The CSS filter string, e.g. "blur(5px) brightness(150%)" + * @returns Array of FilterFunction, or empty array if 'none' or empty. + */ +export function parseFilter(value: string): FilterFunction[] { + if (!value || value.trim() === 'none') { + return []; + } + + const functions: FilterFunction[] = []; + // Regex to match function calls: name(arguments) + // This regex matches: word characters, hyphen, then parentheses with anything inside (non-greedy) + const regex = /([a-zA-Z-]+)\s*\(([^)]+)\)/g; + let match; + + while ((match = regex.exec(value)) !== null) { + const name = match[1].toLowerCase(); + const args = match[2].trim(); + + try { + const filter = parseFilterFunction(name, args); + if (filter) { + functions.push(filter); + } + } catch (err) { + // Skip invalid filter functions but continue parsing others + console.warn(`Invalid filter function: ${name}(${args}) - ${err.message}`); + } + } + + return functions; +} + +function parseFilterFunction(type: string, args: string): FilterFunction | null { + switch (type) { + case 'blur': + return { type: 'blur', value: parseLength(args) }; + case 'brightness': + case 'contrast': + case 'grayscale': + case 'invert': + case 'opacity': + case 'saturate': + case 'sepia': + // These take a percentage or number. For brightness, 100% = 1, >100% brighter. + // We'll store as a number where 1 = 100% (or 1 for blur? Actually blur uses length). + const percent = parsePercentage(args); + return { type: type as any, value: percent }; + case 'hue-rotate': + // Angle in deg, rad, etc. We'll store in degrees for simplicity. + const angle = parseAngle(args); + return { type: 'hue-rotate', value: angle }; + case 'drop-shadow': + // Syntax: offset-x offset-y blur-radius? color? + // The color is optional and usually last if present. + // We need to split args while keeping color together if it contains spaces (like "rgb(0,0,0)"). + // Simple approach: try to parse from left: first two are lengths (required), third optional length, rest is color. + const parts = splitDropShadowArgs(args); + if (parts.length < 2) { + throw new Error('drop-shadow requires at least offset-x and offset-y'); + } + const h = parseLength(parts[0]); + const v = parseLength(parts[1]); + const blur = parts.length >= 3 ? parseLength(parts[2]) : 0; + const color = parts.length > 3 ? parts.slice(3).join(' ') : undefined; + return { + type: 'drop-shadow', + value: { h, v, blur, color: color } // color may be undefined + }; + default: + // Unknown filter type, ignore + return null; + } +} + +function parseLength(str: string): number { + // Remove unit and parse number. For blur, unit is typically px. We'll return value in pixels. + // If no unit, assume pixels? CSS filter blur radius is in px. + const num = parseFloat(str); + if (isNaN(num)) { + throw new Error(`Invalid length: ${str}`); + } + return num; +} + +function parsePercentage(str: string): number { + // Remove % sign if present and return fraction (e.g., 150% => 1.5) + let s = str.trim(); + if (s.endsWith('%')) { + s = s.slice(0, -1); + } + const num = parseFloat(s) / 100; + if (isNaN(num)) { + throw new Error(`Invalid percentage: ${str}`); + } + return num; +} + +function parseAngle(str: string): number { + // Convert angle to degrees. Supported units: deg, rad, grad, turn. + const s = str.trim(); + if (s.endsWith('deg')) { + return parseFloat(s.slice(0, -3)); + } else if (s.endsWith('rad')) { + const rad = parseFloat(s.slice(0, -3)); + return rad * (180 / Math.PI); + } else if (s.endsWith('grad')) { + const grad = parseFloat(s.slice(0, -4)); + return grad * 0.9; // 100 grad = 90 deg + } else if (s.endsWith('turn')) { + const turns = parseFloat(s.slice(0, -4)); + return turns * 360; + } else { + // Assume degrees if no unit? CSS requires unit for angle. But we'll default to deg. + return parseFloat(s); + } +} + +function splitDropShadowArgs(args: string): string[] { + // We need to split by spaces but not inside parentheses or functions like rgb(). + // Simple approach: split by whitespace, then try to recombine color if it contains spaces. + // We'll split on whitespace that is not inside parentheses. + const parts: string[] = []; + let current = ''; + let parenDepth = 0; + + for (let i = 0; i < args.length; i++) { + const char = args[i]; + if (char === '(') { + parenDepth++; + current += char; + } else if (char === ')') { + parenDepth--; + current += char; + } else if (char === ' ' && parenDepth === 0) { + if (current) { + parts.push(current); + current = ''; + } + } else { + current += char; + } + } + if (current) { + parts.push(current); + } + + // If we have more than 3 parts, the rest should be considered color (which may have spaces) + // But if the color is something like "rgb(0, 0, 0)" it's already one part because we didn't split inside parentheses. + // So if we have exactly 4 parts, that's fine. If we have >4, combine everything after index 2 into color. + if (parts.length > 3) { + const combinedColor = parts.slice(2).join(' '); + return [parts[0], parts[1], combinedColor]; + } + return parts; +} diff --git a/packages/core/ui/styling/style-properties.ts b/packages/core/ui/styling/style-properties.ts index bb65f7f602..0f0d929ff1 100644 --- a/packages/core/ui/styling/style-properties.ts +++ b/packages/core/ui/styling/style-properties.ts @@ -17,6 +17,7 @@ import { parseCSSShadow, ShadowCSSValues } from './css-shadow'; import { transformConverter } from './css-transform'; import { ClipPathFunction } from './clip-path-function'; import { parseCSSCommaSeparatedListOfValues } from './css-utils'; +import { parseFilter, FilterFunction } from './filter-parser'; interface ShorthandPositioning { top: string; @@ -1250,3 +1251,20 @@ export const androidDynamicElevationOffsetProperty = new CssProperty({ + name: 'filter', + cssName: 'filter', + defaultValue: [], + equalityComparer: (a, b) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) return false; + } + return true; + }, + valueConverter: (value: string): FilterFunction[] => { + return parseFilter(value); + }, +}); +filterProperty.register(Style);