From c3326e5dd009149e1e732df2dec56940f3369318 Mon Sep 17 00:00:00 2001 From: "Q. Omair Ahmed" Date: Sun, 21 Dec 2025 18:55:30 -0500 Subject: [PATCH 01/26] feat(slider): add linear-gradient support for track background Enable using CSS linear-gradient for the Slider component's track. This allows setting gradient backgrounds via the background-image CSS property or programmatically. On Android: - Creates LinearGradient shader applied to ShapeDrawable - Sets gradient on both progress and background layers of SeekBar On iOS: - Creates CAGradientLayer and renders to UIImage - Sets resizable track images for min/max track states Closes #5940 --- apps/automated/src/ui/slider/slider-tests.ts | 63 ++++++++++++ packages/core/ui/slider/index.android.ts | 79 +++++++++++++- packages/core/ui/slider/index.ios.ts | 103 ++++++++++++++++++- 3 files changed, 243 insertions(+), 2 deletions(-) diff --git a/apps/automated/src/ui/slider/slider-tests.ts b/apps/automated/src/ui/slider/slider-tests.ts index 0fded42417..df8254add2 100644 --- a/apps/automated/src/ui/slider/slider-tests.ts +++ b/apps/automated/src/ui/slider/slider-tests.ts @@ -6,6 +6,8 @@ import { BindingOptions, View, Page, Observable, EventData, PropertyChangeData, import { Slider } from '@nativescript/core/ui/slider'; // << article-require-slider +import { LinearGradient } from '@nativescript/core/ui/styling/linear-gradient'; + // ### Binding the Progress and Slider value properties to a observable view-model property. // >> article-binding-slider-properties @@ -121,6 +123,67 @@ export function test_set_backgroundColor() { } } +export function test_set_linear_gradient_background() { + const slider = new Slider(); + + // Create a linear gradient programmatically + const gradient = new LinearGradient(); + gradient.angle = Math.PI / 2; // 90 degrees (left to right) + gradient.colorStops = [{ color: new Color('red') }, { color: new Color('green') }, { color: new Color('blue') }]; + + function testAction(views: Array) { + // Set the gradient via the style's backgroundImage + slider.style.backgroundImage = gradient; + + // Verify the slider was created and the gradient was applied + TKUnit.assertNotNull(slider, 'slider should not be null'); + + if (__APPLE__) { + // On iOS, verify that track images were set + const minTrackImage = slider.ios.minimumTrackImageForState(UIControlState.Normal); + const maxTrackImage = slider.ios.maximumTrackImageForState(UIControlState.Normal); + TKUnit.assertNotNull(minTrackImage, 'minimumTrackImage should be set after applying gradient'); + TKUnit.assertNotNull(maxTrackImage, 'maximumTrackImage should be set after applying gradient'); + } else if (__ANDROID__) { + // On Android, verify the progress drawable was modified + const progressDrawable = slider.android.getProgressDrawable(); + TKUnit.assertNotNull(progressDrawable, 'progressDrawable should not be null'); + } + } + + helper.buildUIAndRunTest(slider, testAction); +} + +export function test_set_linear_gradient_with_stops() { + const slider = new Slider(); + + // Create a linear gradient with explicit color stops + const gradient = new LinearGradient(); + gradient.angle = 0; // 0 degrees (bottom to top) + gradient.colorStops = [ + { color: new Color('orangered'), offset: { unit: '%', value: 0 } }, + { color: new Color('green'), offset: { unit: '%', value: 0.5 } }, + { color: new Color('lightblue'), offset: { unit: '%', value: 1 } }, + ]; + + function testAction(views: Array) { + slider.style.backgroundImage = gradient; + + // Verify the slider was created + TKUnit.assertNotNull(slider, 'slider should not be null'); + + if (__APPLE__) { + const minTrackImage = slider.ios.minimumTrackImageForState(UIControlState.Normal); + TKUnit.assertNotNull(minTrackImage, 'minimumTrackImage should be set after applying gradient with stops'); + } else if (__ANDROID__) { + const progressDrawable = slider.android.getProgressDrawable(); + TKUnit.assertNotNull(progressDrawable, 'progressDrawable should not be null'); + } + } + + helper.buildUIAndRunTest(slider, testAction); +} + export function test_default_TNS_values() { const slider = new Slider(); TKUnit.assertEqual(slider.value, 0, 'Default slider.value'); diff --git a/packages/core/ui/slider/index.android.ts b/packages/core/ui/slider/index.android.ts index f76b283f78..0f3c9977ff 100644 --- a/packages/core/ui/slider/index.android.ts +++ b/packages/core/ui/slider/index.android.ts @@ -3,6 +3,7 @@ import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from '. import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { Color } from '../../color'; import { AndroidHelper } from '../core/view'; +import { LinearGradient } from '../styling/linear-gradient'; export * from './slider-common'; @@ -142,6 +143,82 @@ export class Slider extends SliderBase { return null; } [backgroundInternalProperty.setNative](value: Background) { - // + if (value && value.image instanceof LinearGradient) { + this._applyGradientToTrack(value.image); + } + } + + private _applyGradientToTrack(gradient: LinearGradient): void { + const nativeView = this.nativeViewProtected; + if (!nativeView) { + return; + } + + // Create colors array from gradient stops + const colors = Array.create('int', gradient.colorStops.length); + const positions = Array.create('float', gradient.colorStops.length); + let hasPositions = false; + + gradient.colorStops.forEach((stop, index) => { + colors[index] = stop.color.android; + if (stop.offset) { + positions[index] = stop.offset.value; + hasPositions = true; + } else { + // Default evenly distributed positions + positions[index] = index / (gradient.colorStops.length - 1); + } + }); + + // Calculate gradient direction based on angle + 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); + + // Create the shape drawable with gradient + const shape = new android.graphics.drawable.shapes.RectShape(); + const shapeDrawable = new android.graphics.drawable.ShapeDrawable(shape); + + // We need to set the bounds and shader in a custom callback since the drawable + // doesn't have intrinsic dimensions + const width = nativeView.getWidth() || 1000; // Default width if not yet measured + const height = nativeView.getHeight() || 50; // Default height for progress drawable + + const linearGradient = new android.graphics.LinearGradient(startX * width, startY * height, endX * width, endY * height, colors, hasPositions ? positions : null, android.graphics.Shader.TileMode.CLAMP); + + shapeDrawable.getPaint().setShader(linearGradient); + shapeDrawable.setBounds(0, 0, width, height); + + // Create a layer drawable that wraps the gradient for progress + const progressDrawable = nativeView.getProgressDrawable(); + + if (progressDrawable instanceof android.graphics.drawable.LayerDrawable) { + // The SeekBar progress drawable is typically a LayerDrawable with 3 layers: + // 0 - background track + // 1 - secondary progress (buffer) + // 2 - progress (filled portion) + const layerDrawable = progressDrawable as android.graphics.drawable.LayerDrawable; + + // Create a clip drawable for the progress layer so it clips based on progress + const clipDrawable = new android.graphics.drawable.ClipDrawable(shapeDrawable, android.view.Gravity.LEFT, android.graphics.drawable.ClipDrawable.HORIZONTAL); + + // Set the gradient drawable as the progress layer + layerDrawable.setDrawableByLayerId(android.R.id.progress, clipDrawable); + + // Also set it as the background track for full gradient visibility + const backgroundShape = new android.graphics.drawable.ShapeDrawable(new android.graphics.drawable.shapes.RectShape()); + const bgGradient = new android.graphics.LinearGradient(startX * width, startY * height, endX * width, endY * height, colors, hasPositions ? positions : null, android.graphics.Shader.TileMode.CLAMP); + backgroundShape.getPaint().setShader(bgGradient); + backgroundShape.getPaint().setAlpha(77); // ~30% opacity for background + backgroundShape.setBounds(0, 0, width, height); + layerDrawable.setDrawableByLayerId(android.R.id.background, backgroundShape); + + nativeView.setProgressDrawable(layerDrawable); + } else { + // Fallback: just set the shape drawable directly + nativeView.setProgressDrawable(shapeDrawable); + } } } diff --git a/packages/core/ui/slider/index.ios.ts b/packages/core/ui/slider/index.ios.ts index 95ccd106ed..8ea3a727a2 100644 --- a/packages/core/ui/slider/index.ios.ts +++ b/packages/core/ui/slider/index.ios.ts @@ -4,6 +4,8 @@ import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from '. import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties'; import { Color } from '../../color'; import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.'; +import { LinearGradient } from '../styling/linear-gradient'; +import { Screen } from '../../platform'; export * from './slider-common'; @@ -131,7 +133,106 @@ export class Slider extends SliderBase { return null; } [backgroundInternalProperty.setNative](value: Background) { - // + if (value && value.image instanceof LinearGradient) { + this._applyGradientToTrack(value.image); + } + } + + private _applyGradientToTrack(gradient: LinearGradient): void { + const nativeView = this.nativeViewProtected; + if (!nativeView) { + return; + } + + // Create a gradient layer + const gradientLayer = CAGradientLayer.new(); + + // Set up colors from the gradient stops + const iosColors = NSMutableArray.alloc().initWithCapacity(gradient.colorStops.length); + const iosStops = NSMutableArray.alloc().initWithCapacity(gradient.colorStops.length); + let hasStops = false; + + gradient.colorStops.forEach((stop, index) => { + iosColors.addObject(stop.color.ios.CGColor); + if (stop.offset) { + iosStops.addObject(stop.offset.value); + hasStops = true; + } else { + // Default evenly distributed positions + iosStops.addObject(index / (gradient.colorStops.length - 1)); + } + }); + + gradientLayer.colors = iosColors; + if (hasStops) { + gradientLayer.locations = iosStops; + } + + // Calculate gradient direction based on angle + 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); + + gradientLayer.startPoint = { x: startX, y: startY }; + gradientLayer.endPoint = { x: endX, y: endY }; + + // Create track image from gradient + // Use a reasonable default size for the track + const trackWidth = 200; + const trackHeight = 4; + + gradientLayer.frame = CGRectMake(0, 0, trackWidth, trackHeight); + gradientLayer.cornerRadius = trackHeight / 2; + + // Render gradient to image + UIGraphicsBeginImageContextWithOptions(CGSizeMake(trackWidth, trackHeight), false, Screen.mainScreen.scale); + const context = UIGraphicsGetCurrentContext(); + if (context) { + gradientLayer.renderInContext(context); + const gradientImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (gradientImage) { + // Create stretchable image for the track + const capInsets = new UIEdgeInsets({ + top: 0, + left: trackHeight / 2, + bottom: 0, + right: trackHeight / 2, + }); + const stretchableImage = gradientImage.resizableImageWithCapInsetsResizingMode(capInsets, UIImageResizingMode.Stretch); + + // Set the gradient image for minimum track (filled portion) + nativeView.setMinimumTrackImageForState(stretchableImage, UIControlState.Normal); + + // For maximum track, create a semi-transparent version + UIGraphicsBeginImageContextWithOptions(CGSizeMake(trackWidth, trackHeight), false, Screen.mainScreen.scale); + const maxContext = UIGraphicsGetCurrentContext(); + if (maxContext) { + CGContextSetAlpha(maxContext, 0.3); + gradientLayer.renderInContext(maxContext); + const maxTrackImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (maxTrackImage) { + const maxCapInsets = new UIEdgeInsets({ + top: 0, + left: trackHeight / 2, + bottom: 0, + right: trackHeight / 2, + }); + const maxStretchableImage = maxTrackImage.resizableImageWithCapInsetsResizingMode(maxCapInsets, UIImageResizingMode.Stretch); + nativeView.setMaximumTrackImageForState(maxStretchableImage, UIControlState.Normal); + } + } else { + UIGraphicsEndImageContext(); + } + } + } else { + UIGraphicsEndImageContext(); + } } private getAccessibilityStep(): number { From 561cd5097cccdc1ce39cdca3ca9686af3d22e1a0 Mon Sep 17 00:00:00 2001 From: "Q. Omair Ahmed" Date: Mon, 22 Dec 2025 19:52:52 -0500 Subject: [PATCH 02/26] docs(toolbox): add sliders demo page for linear-gradient feature Add sample page to toolbox demonstrating gradient slider backgrounds: - Default slider for comparison - Various gradient styles (rainbow, sunset, ocean, purple) - Gradient with explicit color stops - Disabled gradient slider example --- apps/toolbox/src/app.css | 29 ++++++++++++++++++ apps/toolbox/src/main-page.xml | 1 + apps/toolbox/src/pages/sliders.ts | 14 +++++++++ apps/toolbox/src/pages/sliders.xml | 48 ++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 apps/toolbox/src/pages/sliders.ts create mode 100644 apps/toolbox/src/pages/sliders.xml diff --git a/apps/toolbox/src/app.css b/apps/toolbox/src/app.css index 82f893d56b..21ad68b04e 100644 --- a/apps/toolbox/src/app.css +++ b/apps/toolbox/src/app.css @@ -339,3 +339,32 @@ Button { .text-center { text-align: center; } + +/* Sliders Demo Page Styles */ +.sliders-demo-page Slider.gradient-slider { + background: linear-gradient(to right, orangered, green, lightblue); +} + +.sliders-demo-page Slider.rainbow-slider { + background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet); +} + +.sliders-demo-page Slider.two-color-slider { + background: linear-gradient(to right, #ff6b6b, #4ecdc4); +} + +.sliders-demo-page Slider.sunset-slider { + background: linear-gradient(to right, #f12711, #f5af19); +} + +.sliders-demo-page Slider.ocean-slider { + background: linear-gradient(to right, #2193b0, #6dd5ed); +} + +.sliders-demo-page Slider.purple-slider { + background: linear-gradient(to right, #8e2de2, #4a00e0); +} + +.sliders-demo-page Slider.stops-slider { + background: linear-gradient(to right, red 0%, yellow 50%, green 100%); +} diff --git a/apps/toolbox/src/main-page.xml b/apps/toolbox/src/main-page.xml index 3c72d0b392..43efde9f2c 100644 --- a/apps/toolbox/src/main-page.xml +++ b/apps/toolbox/src/main-page.xml @@ -22,6 +22,7 @@