From f6e2b14fa37bafc66d9e3ad417f4df6bef34770d Mon Sep 17 00:00:00 2001 From: S Manthira Perakshi Sudarson Date: Sat, 15 Nov 2025 23:46:19 +0530 Subject: [PATCH] feat(slider): add support for linear gradients in min/max track properties --- .../src/ui/slider/slider-gradient-tests.ts | 166 ++++++++++++++++++ .../ui/slider/slider-tests-native.android.ts | 17 ++ .../src/ui/slider/slider-tests-native.d.ts | 4 + .../src/ui/slider/slider-tests-native.ios.ts | 17 ++ apps/automated/src/ui/slider/slider-tests.ts | 21 +++ packages/core/__tests__/slider.spec.ts | 13 ++ .../ui/slider/gradient-drawable.android.ts | 86 +++++++++ packages/core/ui/slider/index.android.ts | 33 +++- packages/core/ui/slider/index.d.ts | 23 +++ packages/core/ui/slider/index.ios.ts | 134 +++++++++++++- packages/core/ui/slider/index.shared.ts | 2 + .../ui/slider/slider-accessibilityEvents.ts | 13 ++ packages/core/ui/slider/slider-common.ts | 29 +++ 13 files changed, 552 insertions(+), 6 deletions(-) create mode 100644 apps/automated/src/ui/slider/slider-gradient-tests.ts create mode 100644 apps/automated/src/ui/slider/slider-tests-native.android.ts create mode 100644 apps/automated/src/ui/slider/slider-tests-native.d.ts create mode 100644 apps/automated/src/ui/slider/slider-tests-native.ios.ts create mode 100644 packages/core/__tests__/slider.spec.ts create mode 100644 packages/core/ui/slider/gradient-drawable.android.ts create mode 100644 packages/core/ui/slider/index.shared.ts create mode 100644 packages/core/ui/slider/slider-accessibilityEvents.ts diff --git a/apps/automated/src/ui/slider/slider-gradient-tests.ts b/apps/automated/src/ui/slider/slider-gradient-tests.ts new file mode 100644 index 0000000000..6dcd4a7fe1 --- /dev/null +++ b/apps/automated/src/ui/slider/slider-gradient-tests.ts @@ -0,0 +1,166 @@ +import * as TKUnit from '../../tk-unit'; +import * as helper from '../../ui-helper'; +import * as sliderTestsNative from './slider-tests-native'; +import { Observable, Color } from '@nativescript/core'; +import { LinearGradient } from '@nativescript/core/ui/styling/linear-gradient'; +import { Slider } from '@nativescript/core/ui/slider'; + +export function test_linear_gradient_layer_structure() { + const slider = new Slider(); + + function testAction() { + // Create a test gradient + const gradient = new LinearGradient(); + gradient.angle = 90; + gradient.colorStops = [ + { color: new Color('#FF0000'), offset: { unit: '%', value: 0 } }, + { color: new Color('#00FF00'), offset: { unit: '%', value: 50 } }, + { color: new Color('#0000FF'), offset: { unit: '%', value: 100 } }, + ]; + + // Set the gradient + slider.minTrackGradient = gradient; + + // Get native drawables + const nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + + if (__ANDROID__) { + // Test Android layer structure + TKUnit.assertTrue(nativeProgress instanceof android.graphics.drawable.LayerDrawable, 'Progress drawable should be a LayerDrawable'); + + const layerDrawable = nativeProgress as android.graphics.drawable.LayerDrawable; + TKUnit.assertEqual(layerDrawable.getNumberOfLayers(), 3, 'LayerDrawable should have 3 layers'); + + // Test that our gradient layer is in the correct position (progress layer) + const progressLayer = layerDrawable.getDrawable(layerDrawable.findIndexByLayerId(android.R.id.progress)); + TKUnit.assertTrue(progressLayer instanceof org.nativescript.widgets.LinearGradientDefinition || progressLayer instanceof android.graphics.drawable.ShapeDrawable, 'Progress layer should be a gradient drawable'); + } else if (__IOS__) { + // Test iOS layer structure + TKUnit.assertNotNull(nativeProgress, 'Progress image should not be null'); + TKUnit.assertTrue(nativeProgress instanceof UIImage, 'Progress drawable should be a UIImage'); + } + } + + helper.buildUIAndRunTest(slider, testAction); +} + +export function test_gradient_angle_mapping() { + const slider = new Slider(); + + function testAction() { + const angles = [0, 90, 180, 270]; + + angles.forEach((angle) => { + const gradient = new LinearGradient(); + gradient.angle = angle; + gradient.colorStops = [{ color: new Color('#FF0000') }, { color: new Color('#0000FF') }]; + + slider.minTrackGradient = gradient; + const nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + + if (__ANDROID__) { + const layerDrawable = nativeProgress as android.graphics.drawable.LayerDrawable; + const progressLayer = layerDrawable.getDrawable(layerDrawable.findIndexByLayerId(android.R.id.progress)); + + // Verify the angle is correctly mapped to the native gradient + if (progressLayer instanceof org.nativescript.widgets.LinearGradientDefinition) { + const expectedStartX = angle === 180 ? 1 : angle === 0 ? 0 : 0.5; + const expectedStartY = angle === 270 ? 1 : angle === 90 ? 0 : 0.5; + const expectedEndX = angle === 0 ? 1 : angle === 180 ? 0 : 0.5; + const expectedEndY = angle === 90 ? 1 : angle === 270 ? 0 : 0.5; + + // Validate the coordinates are within acceptable range + const startXDiff = Math.abs(progressLayer.getStartX() - expectedStartX); + const startYDiff = Math.abs(progressLayer.getStartY() - expectedStartY); + const endXDiff = Math.abs(progressLayer.getEndX() - expectedEndX); + const endYDiff = Math.abs(progressLayer.getEndY() - expectedEndY); + + TKUnit.assertTrue(startXDiff <= 0.1, 'Start X coordinate should be approximately ' + expectedStartX); + TKUnit.assertTrue(startYDiff <= 0.1, 'Start Y coordinate should be approximately ' + expectedStartY); + TKUnit.assertTrue(endXDiff <= 0.1, 'End X coordinate should be approximately ' + expectedEndX); + TKUnit.assertTrue(endYDiff <= 0.1, 'End Y coordinate should be approximately ' + expectedEndY); + } + } + }); + } + + helper.buildUIAndRunTest(slider, testAction); +} + +export function test_gradient_color_stops() { + const slider = new Slider(); + + function testAction() { + const gradient = new LinearGradient(); + gradient.angle = 0; + gradient.colorStops = [ + { color: new Color('#FF0000'), offset: { unit: '%', value: 0 } }, + { color: new Color('#00FF00'), offset: { unit: '%', value: 50 } }, + { color: new Color('#0000FF'), offset: { unit: '%', value: 100 } }, + ]; + + slider.minTrackGradient = gradient; + const nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + + if (__ANDROID__) { + const layerDrawable = nativeProgress as android.graphics.drawable.LayerDrawable; + const progressLayer = layerDrawable.getDrawable(layerDrawable.findIndexByLayerId(android.R.id.progress)); + + if (progressLayer instanceof org.nativescript.widgets.LinearGradientDefinition) { + const colors = progressLayer.getColors(); + const stops = progressLayer.getStops(); + + // Test color count + TKUnit.assertEqual(colors.length, 3, 'Should have 3 colors in the gradient'); + + // Test color values + TKUnit.assertEqual(colors[0], new Color('#FF0000').android, 'First color should be red'); + TKUnit.assertEqual(colors[1], new Color('#00FF00').android, 'Second color should be green'); + TKUnit.assertEqual(colors[2], new Color('#0000FF').android, 'Third color should be blue'); + + // Test stops + TKUnit.assertEqual(stops[0], 0, 'First stop should be at 0'); + TKUnit.assertEqual(stops[1], 0.5, 'Second stop should be at 0.5'); + TKUnit.assertEqual(stops[2], 1, 'Third stop should be at 1'); + } + } else if (__IOS__) { + // iOS-specific color stop tests + TKUnit.assertNotNull(nativeProgress, 'Progress image should not be null'); + } + } + + helper.buildUIAndRunTest(slider, testAction); +} + +export function test_gradient_updates() { + const slider = new Slider(); + + function testAction() { + // Initial gradient + const gradient1 = new LinearGradient(); + gradient1.angle = 0; + gradient1.colorStops = [{ color: new Color('#FF0000') }, { color: new Color('#0000FF') }]; + + slider.minTrackGradient = gradient1; + let nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + TKUnit.assertNotNull(nativeProgress, 'Initial gradient should be applied'); + + // Update gradient + const gradient2 = new LinearGradient(); + gradient2.angle = 90; + gradient2.colorStops = [{ color: new Color('#00FF00') }, { color: new Color('#FF00FF') }]; + + slider.minTrackGradient = gradient2; + nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + TKUnit.assertNotNull(nativeProgress, 'Updated gradient should be applied'); + + // Remove gradient + slider.minTrackGradient = null; + nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + if (__ANDROID__) { + TKUnit.assertTrue(nativeProgress instanceof android.graphics.drawable.LayerDrawable, 'Should revert to default LayerDrawable'); + } + } + + helper.buildUIAndRunTest(slider, testAction); +} diff --git a/apps/automated/src/ui/slider/slider-tests-native.android.ts b/apps/automated/src/ui/slider/slider-tests-native.android.ts new file mode 100644 index 0000000000..95af6cbd6d --- /dev/null +++ b/apps/automated/src/ui/slider/slider-tests-native.android.ts @@ -0,0 +1,17 @@ +import { Slider } from '@nativescript/core/ui/slider'; + +export function getNativeMinTrackImage(slider: Slider) { + if (!slider || !slider.android) { + return null; + } + try { + const seekBar = slider.android as android.widget.SeekBar; + return seekBar.getProgressDrawable(); + } catch (e) { + return null; + } +} + +export function getNativeProgressDrawable(slider: Slider) { + return getNativeMinTrackImage(slider); +} diff --git a/apps/automated/src/ui/slider/slider-tests-native.d.ts b/apps/automated/src/ui/slider/slider-tests-native.d.ts new file mode 100644 index 0000000000..e2fb2e7307 --- /dev/null +++ b/apps/automated/src/ui/slider/slider-tests-native.d.ts @@ -0,0 +1,4 @@ +import { Slider } from '@nativescript/core/ui/slider'; + +export function getNativeMinTrackImage(slider: Slider): any; +export function getNativeProgressDrawable(slider: Slider): any; diff --git a/apps/automated/src/ui/slider/slider-tests-native.ios.ts b/apps/automated/src/ui/slider/slider-tests-native.ios.ts new file mode 100644 index 0000000000..1ea408765b --- /dev/null +++ b/apps/automated/src/ui/slider/slider-tests-native.ios.ts @@ -0,0 +1,17 @@ +import { Slider } from '@nativescript/core/ui/slider'; + +export function getNativeMinTrackImage(slider: Slider) { + if (!slider || !slider.ios) { + return null; + } + try { + return slider.ios.minimumTrackImageForState(UIControlState.Normal); + } catch (e) { + return null; + } +} + +export function getNativeProgressDrawable(slider: Slider) { + // On iOS the equivalent for progress drawable is minimum/maximum track images; return minimum track image + return getNativeMinTrackImage(slider); +} diff --git a/apps/automated/src/ui/slider/slider-tests.ts b/apps/automated/src/ui/slider/slider-tests.ts index 0fded42417..ca0187ca7c 100644 --- a/apps/automated/src/ui/slider/slider-tests.ts +++ b/apps/automated/src/ui/slider/slider-tests.ts @@ -1,6 +1,8 @@ import * as TKUnit from '../../tk-unit'; import * as helper from '../../ui-helper'; +import * as sliderTestsNative from './slider-tests-native'; import { BindingOptions, View, Page, Observable, EventData, PropertyChangeData, Color } from '@nativescript/core'; +import { LinearGradient } from '@nativescript/core/ui/styling/linear-gradient'; // >> article-require-slider import { Slider } from '@nativescript/core/ui/slider'; @@ -527,3 +529,22 @@ function setNativeValue(slider: Slider, value: number) { slider.ios.sendActionsForControlEvents(UIControlEvents.ValueChanged); } } + +export function test_set_gradients_native() { + const slider = new Slider(); + function testAction(views: Array) { + const gradient = new LinearGradient(); + gradient.angle = 0; + gradient.colorStops = [{ color: new Color('#ff0000') }, { color: new Color('#00ff00') }]; + + slider.minTrackGradient = gradient; + slider.maxTrackGradient = gradient; + + const nativeMin = sliderTestsNative.getNativeMinTrackImage(slider); + const nativeProgress = sliderTestsNative.getNativeProgressDrawable(slider); + + TKUnit.assert(nativeMin !== null || nativeProgress !== null, 'Native gradient drawable or image should be applied on either platform'); + } + + helper.buildUIAndRunTest(new Slider(), testAction); +} diff --git a/packages/core/__tests__/slider.spec.ts b/packages/core/__tests__/slider.spec.ts new file mode 100644 index 0000000000..fc132cf6cd --- /dev/null +++ b/packages/core/__tests__/slider.spec.ts @@ -0,0 +1,13 @@ +import { minTrackGradientProperty, maxTrackGradientProperty } from '../ui/slider/slider-common'; + +describe('Slider gradient properties (unit)', () => { + test('minTrackGradientProperty is exported', () => { + expect(minTrackGradientProperty).toBeDefined(); + expect(minTrackGradientProperty.name).toBe('minTrackGradient'); + }); + + test('maxTrackGradientProperty is exported', () => { + expect(maxTrackGradientProperty).toBeDefined(); + expect(maxTrackGradientProperty.name).toBe('maxTrackGradient'); + }); +}); diff --git a/packages/core/ui/slider/gradient-drawable.android.ts b/packages/core/ui/slider/gradient-drawable.android.ts new file mode 100644 index 0000000000..016ac1d8f7 --- /dev/null +++ b/packages/core/ui/slider/gradient-drawable.android.ts @@ -0,0 +1,86 @@ +import { LinearGradient } from '../styling/linear-gradient'; + +declare module 'globals' { + export const global: any; +} +declare const global: any; + +export function fromGradient(gradient: LinearGradient): org.nativescript.widgets.LinearGradientDefinition { + const colors = Array.create('int', gradient.colorStops.length); + const stops = Array.create('float', gradient.colorStops.length); + let hasStops = false; + gradient.colorStops.forEach((stop, index) => { + colors[index] = stop.color.android; + if (stop.offset) { + stops[index] = stop.offset.value / 100; // Convert percentage to decimal + hasStops = true; + } + }); + + const alpha = gradient.angle / (Math.PI * 2); + const startX = Math.pow(Math.sin(Math.PI * (alpha + 0.75)), 2); + const startY = Math.pow(Math.sin(Math.PI * (alpha + 0.5)), 2); + const endX = Math.pow(Math.sin(Math.PI * (alpha + 0.25)), 2); + const endY = Math.pow(Math.sin(Math.PI * alpha), 2); + + return new org.nativescript.widgets.LinearGradientDefinition(startX, startY, endX, endY, colors, hasStops ? stops : null); +} + +@NativeClass() +class ShaderDrawable extends android.graphics.drawable.ShapeDrawable { + private gradient: org.nativescript.widgets.LinearGradientDefinition; + private paint: android.graphics.Paint; + + constructor(gradient: org.nativescript.widgets.LinearGradientDefinition) { + super(new android.graphics.drawable.shapes.RectShape()); + this.gradient = gradient; + this.paint = this.getPaint(); + return global.__native(this); + } + + public onBoundsChange(bounds: android.graphics.Rect): void { + super.onBoundsChange(bounds); + this.updateShader(bounds); + } + + private updateShader(bounds: android.graphics.Rect): void { + const width = bounds.width(); + const height = bounds.height(); + + if (width <= 0 || height <= 0) { + return; + } + + const shader = new android.graphics.LinearGradient(this.gradient.getStartX() * width, this.gradient.getStartY() * height, this.gradient.getEndX() * width, this.gradient.getEndY() * height, this.gradient.getColors(), this.gradient.getStops(), android.graphics.Shader.TileMode.CLAMP); + + this.paint.setShader(shader); + this.invalidateSelf(); + } +} + +@NativeClass() +export class GradientDrawable extends android.graphics.drawable.LayerDrawable { + constructor(gradient: LinearGradient, defaultDrawable: android.graphics.drawable.LayerDrawable) { + const drawableCount = defaultDrawable.getNumberOfLayers(); + const drawables = Array.create('android.graphics.drawable.Drawable', drawableCount); + const shaderDrawable = new ShaderDrawable(fromGradient(gradient)); + + for (let i = 0; i < drawableCount; i++) { + const id = defaultDrawable.getId(i); + if (id === android.R.id.progress) { + drawables[i] = shaderDrawable; + } else { + drawables[i] = defaultDrawable.getDrawable(i); + } + } + + super(drawables); + + // Copy layer IDs from original drawable + for (let i = 0; i < drawableCount; i++) { + this.setId(i, defaultDrawable.getId(i)); + } + + return global.__native(this); + } +} diff --git a/packages/core/ui/slider/index.android.ts b/packages/core/ui/slider/index.android.ts index f76b283f78..dea8bb9890 100644 --- a/packages/core/ui/slider/index.android.ts +++ b/packages/core/ui/slider/index.android.ts @@ -1,10 +1,12 @@ import { Background } from '../styling/background'; -import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from './slider-common'; +import { SliderBase, valueProperty, minValueProperty, maxValueProperty, minTrackGradientProperty, maxTrackGradientProperty } from './index.shared'; import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { Color } from '../../color'; import { AndroidHelper } from '../core/view'; +import { LinearGradient } from '../styling/linear-gradient'; +import { GradientDrawable } from './gradient-drawable.android'; -export * from './slider-common'; +export * from './index.shared'; interface OwnerSeekBar extends android.widget.SeekBar { owner: Slider; @@ -61,6 +63,8 @@ export class Slider extends SliderBase { return new SeekBar(this._context); } + private _defaultProgressDrawable: android.graphics.drawable.Drawable; + public initNativeView(): void { super.initNativeView(); const nativeView = this.nativeViewProtected; @@ -144,4 +148,29 @@ export class Slider extends SliderBase { [backgroundInternalProperty.setNative](value: Background) { // } + + [minTrackGradientProperty.setNative](value: LinearGradient | null) { + const nativeView = this.nativeViewProtected; + if (!nativeView) { + return; + } + if (!this._defaultProgressDrawable) { + this._defaultProgressDrawable = nativeView.getProgressDrawable(); + } + + if (!value) { + // restore original drawable + nativeView.setProgressDrawable(this._defaultProgressDrawable); + return; + } + + // Create a new drawable with shader-based gradient + const drawable = new GradientDrawable(value, this._defaultProgressDrawable); + nativeView.setProgressDrawable(drawable); + } + + [maxTrackGradientProperty.setNative](value: LinearGradient | null) { + // For now apply same drawable as min track as SeekBar uses a single progress drawable. + this[minTrackGradientProperty.setNative](value as any); + } } diff --git a/packages/core/ui/slider/index.d.ts b/packages/core/ui/slider/index.d.ts index a23e207695..0584ae8db0 100644 --- a/packages/core/ui/slider/index.d.ts +++ b/packages/core/ui/slider/index.d.ts @@ -2,6 +2,7 @@ import { Property, CoercibleProperty } from '../core/properties'; import { EventData } from '../../data/observable'; import type { SliderBase } from './slider-common'; +import type { LinearGradient } from '../styling/linear-gradient'; /** * Represents a slider component. @@ -66,6 +67,22 @@ export class Slider extends View { * @nsProperty */ accessibilityStep: number; + + /** + * Linear gradient used to paint the minimum/fill track (left of the thumb). + * When set, it overrides minimum track tint behavior. + * + * @nsProperty + */ + minTrackGradient: LinearGradient | null; + + /** + * Linear gradient used to paint the maximum/unfilled track (right of the thumb). + * When set, it overrides maximum track tint behavior. + * + * @nsProperty + */ + maxTrackGradient: LinearGradient | null; } /** @@ -88,6 +105,12 @@ export const maxValueProperty: CoercibleProperty; */ export const accessibilityStepProperty: Property; +/** + * Gradient properties backing each Slider instance. + */ +export const minTrackGradientProperty: Property; +export const maxTrackGradientProperty: Property; + interface AccessibilityIncrementEventData extends EventData { object: Slider; value?: number; diff --git a/packages/core/ui/slider/index.ios.ts b/packages/core/ui/slider/index.ios.ts index 95ccd106ed..98d406d374 100644 --- a/packages/core/ui/slider/index.ios.ts +++ b/packages/core/ui/slider/index.ios.ts @@ -1,11 +1,13 @@ import { Background } from '../styling/background'; -import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from './slider-common'; +import { SliderBase, valueProperty, minValueProperty, maxValueProperty, minTrackGradientProperty, maxTrackGradientProperty } from './index.shared'; import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { Color } from '../../color'; -import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.'; +import { Screen } from '../../platform'; +import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from './slider-accessibilityEvents'; +import { LinearGradient } from '../styling/linear-gradient'; -export * from './slider-common'; +export * from './index.shared'; @NativeClass() class TNSSlider extends UISlider { @@ -134,6 +136,130 @@ export class Slider extends SliderBase { // } + [minTrackGradientProperty.setNative](value: LinearGradient | null) { + if (!this.ios) { + return; + } + + if (!value) { + // Reset to tint color behavior by clearing custom images + this.ios.setMinimumTrackImageForState(null, UIControlState.Normal); + return; + } + + // Calculate optimal size based on screen density and track height + // Get track rect with proper conversion to CGRect + const bounds = this.ios.bounds; + const trackRect = this.ios.trackRectForBounds(bounds as CGRect); + const height = Math.max(2, trackRect.size.height); + // Width is set to 3x height to ensure proper gradient quality when scaled + const size = CGSizeMake(height * 3, height); + const image = this.gradientToImage(value, size, true); + this.ios.setMinimumTrackImageForState(image, UIControlState.Normal); + } + + [maxTrackGradientProperty.setNative](value: LinearGradient | null) { + if (!this.ios) { + return; + } + + if (!value) { + this.ios.setMaximumTrackImageForState(null, UIControlState.Normal); + return; + } + + // Calculate optimal size based on screen density and track height + // Get track rect with proper conversion to CGRect + const bounds = this.ios.bounds; + const trackRect = this.ios.trackRectForBounds(bounds as CGRect); + const height = Math.max(2, trackRect.size.height); + // Width is set to 3x height to ensure proper gradient quality when scaled + const size = CGSizeMake(height * 3, height); + const image = this.gradientToImage(value, size, false); + this.ios.setMaximumTrackImageForState(image, UIControlState.Normal); + } + + private gradientToImage = (gradient: LinearGradient, size: CGSize, isMinTrack: boolean): UIImage => { + const scale = Screen.mainScreen.scale || 1; + const rect = CGRectMake(0, 0, size.width, size.height); + + // Begin graphics context with proper scale + UIGraphicsBeginImageContextWithOptions(rect.size as any, false, scale); + const context = UIGraphicsGetCurrentContext(); + + if (!context) { + console.error('Failed to create graphics context'); + return UIImage.new(); + } + + // Create CAGradientLayer + const gradientLayer = CAGradientLayer.layer(); + const colors = NSMutableArray.new(); + const locations = NSMutableArray.new(); + + // Process color stops with proper offset handling + for (let i = 0; i < gradient.colorStops.length; i++) { + const stop = gradient.colorStops[i]; + colors.addObject(stop.color.ios.CGColor); + + let offset: number; + if (stop.offset) { + offset = stop.offset.unit === '%' ? stop.offset.value / 100 : stop.offset.value; + } else { + // If no offset specified, distribute evenly + offset = i / (gradient.colorStops.length - 1); + } + locations.addObject(NSNumber.numberWithFloat(offset)); + } + + gradientLayer.colors = colors; + gradientLayer.locations = locations; + + // Convert angle to start/end points with proper orientation + const angle = (gradient.angle || 0) * (Math.PI / 180); + let startPoint: CGPoint; + let endPoint: CGPoint; + + // Handle vertical gradients specially for better track appearance + if (Math.abs(Math.sin(angle)) > 0.99) { + // For vertical gradients (90° or 270°), use full height + startPoint = CGPointMake(0.5, angle > 0 ? 0 : 1); + endPoint = CGPointMake(0.5, angle > 0 ? 1 : 0); + } else { + // For all other angles, use the standard calculation + const halfX = Math.cos(angle); + const halfY = Math.sin(angle); + startPoint = CGPointMake(0.5 - halfX / 2, 0.5 - halfY / 2); + endPoint = CGPointMake(0.5 + halfX / 2, 0.5 + halfY / 2); + } + + gradientLayer.startPoint = startPoint; + gradientLayer.endPoint = endPoint; + gradientLayer.frame = rect; + + // Apply corner radius if needed for rounded track appearance + gradientLayer.cornerRadius = size.height / 2; + + // Render gradient layer + gradientLayer.renderInContext(context); + + // Get image from context + const image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (!image) { + console.error('Failed to create gradient image'); + return UIImage.new(); + } + + // Calculate proper insets for resizable image + // Center portion is 1px wide to ensure clean stretching + const capWidth = Math.floor(size.width / 3); + const insets = { top: 0, left: capWidth, bottom: 0, right: capWidth }; + + return image.resizableImageWithCapInsets(insets as UIEdgeInsets); + }; + private getAccessibilityStep(): number { if (!this.accessibilityStep || this.accessibilityStep <= 0) { return 10; @@ -157,7 +283,7 @@ export class Slider extends SliderBase { public _handlerAccessibilityDecrementEvent(): number { const args: AccessibilityDecrementEventData = { object: this, - eventName: SliderBase.accessibilityIncrementEvent, + eventName: SliderBase.accessibilityDecrementEvent, value: this.value - this.getAccessibilityStep(), }; diff --git a/packages/core/ui/slider/index.shared.ts b/packages/core/ui/slider/index.shared.ts new file mode 100644 index 0000000000..41d9ba7bde --- /dev/null +++ b/packages/core/ui/slider/index.shared.ts @@ -0,0 +1,2 @@ +export { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.'; +export { SliderBase, valueProperty, minValueProperty, maxValueProperty, minTrackGradientProperty, maxTrackGradientProperty } from './slider-common'; diff --git a/packages/core/ui/slider/slider-accessibilityEvents.ts b/packages/core/ui/slider/slider-accessibilityEvents.ts new file mode 100644 index 0000000000..2da98e62cc --- /dev/null +++ b/packages/core/ui/slider/slider-accessibilityEvents.ts @@ -0,0 +1,13 @@ +import { EventData } from '../../data/observable'; + +export interface AccessibilityDecrementEventData extends EventData { + object: any; // The slider object + eventName: string; + value: number; +} + +export interface AccessibilityIncrementEventData extends EventData { + object: any; // The slider object + eventName: string; + value: number; +} diff --git a/packages/core/ui/slider/slider-common.ts b/packages/core/ui/slider/slider-common.ts index fd20f65697..31eb1e5a51 100644 --- a/packages/core/ui/slider/slider-common.ts +++ b/packages/core/ui/slider/slider-common.ts @@ -2,6 +2,7 @@ import { Slider as SliderDefinition } from '.'; import { AccessibilityRole } from '../../accessibility'; import { CoercibleProperty, Property } from '../core/properties'; import { CSSType, View } from '../core/view'; +import { LinearGradient } from '../styling/linear-gradient'; // TODO: Extract base Range class for slider and progress @CSSType('Slider') @@ -14,6 +15,10 @@ export class SliderBase extends View implements SliderDefinition { public minValue: number; public maxValue: number; + // Optional gradients for the filled (min/left) and unfilled (max/right) tracks. + public minTrackGradient: LinearGradient | null; + public maxTrackGradient: LinearGradient | null; + get accessibilityStep(): number { return this.style.accessibilityStep; } @@ -75,3 +80,27 @@ export const maxValueProperty = new CoercibleProperty({ valueConverter: (v) => (__APPLE__ ? parseFloat(v) : parseInt(v)), }); maxValueProperty.register(SliderBase); + +/** + * Represents the observable property backing the minTrackGradient property of each Slider instance. + */ +export const minTrackGradientProperty = new Property({ + name: 'minTrackGradient', + defaultValue: null, + valueChanged: (target, oldValue, newValue) => { + // Platform specific handlers will observe and apply gradients when native view is available. + }, +}); +minTrackGradientProperty.register(SliderBase); + +/** + * Represents the observable property backing the maxTrackGradient property of each Slider instance. + */ +export const maxTrackGradientProperty = new Property({ + name: 'maxTrackGradient', + defaultValue: null, + valueChanged: (target, oldValue, newValue) => { + // Platform specific handlers will observe and apply gradients when native view is available. + }, +}); +maxTrackGradientProperty.register(SliderBase);