Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion packages/core/ui/core/view/index.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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());
Expand Down
110 changes: 109 additions & 1 deletion packages/core/ui/core/view/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -903,6 +904,113 @@ export class View extends ViewCommon {
}
}

[filterProperty.getDefault](): FilterFunction[] {
return [];
}

[filterProperty.setNative](value: FilterFunction[]) {
const nativeView: NativeScriptUIView = <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;
}
Expand Down
100 changes: 100 additions & 0 deletions packages/core/ui/styling/filter-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading