Skip to content

Commit c3326e5

Browse files
committed
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
1 parent fefa1d5 commit c3326e5

File tree

3 files changed

+243
-2
lines changed

3 files changed

+243
-2
lines changed

apps/automated/src/ui/slider/slider-tests.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { BindingOptions, View, Page, Observable, EventData, PropertyChangeData,
66
import { Slider } from '@nativescript/core/ui/slider';
77
// << article-require-slider
88

9+
import { LinearGradient } from '@nativescript/core/ui/styling/linear-gradient';
10+
911
// ### Binding the Progress and Slider value properties to a observable view-model property.
1012

1113
// >> article-binding-slider-properties
@@ -121,6 +123,67 @@ export function test_set_backgroundColor() {
121123
}
122124
}
123125

126+
export function test_set_linear_gradient_background() {
127+
const slider = new Slider();
128+
129+
// Create a linear gradient programmatically
130+
const gradient = new LinearGradient();
131+
gradient.angle = Math.PI / 2; // 90 degrees (left to right)
132+
gradient.colorStops = [{ color: new Color('red') }, { color: new Color('green') }, { color: new Color('blue') }];
133+
134+
function testAction(views: Array<View>) {
135+
// Set the gradient via the style's backgroundImage
136+
slider.style.backgroundImage = gradient;
137+
138+
// Verify the slider was created and the gradient was applied
139+
TKUnit.assertNotNull(slider, 'slider should not be null');
140+
141+
if (__APPLE__) {
142+
// On iOS, verify that track images were set
143+
const minTrackImage = slider.ios.minimumTrackImageForState(UIControlState.Normal);
144+
const maxTrackImage = slider.ios.maximumTrackImageForState(UIControlState.Normal);
145+
TKUnit.assertNotNull(minTrackImage, 'minimumTrackImage should be set after applying gradient');
146+
TKUnit.assertNotNull(maxTrackImage, 'maximumTrackImage should be set after applying gradient');
147+
} else if (__ANDROID__) {
148+
// On Android, verify the progress drawable was modified
149+
const progressDrawable = slider.android.getProgressDrawable();
150+
TKUnit.assertNotNull(progressDrawable, 'progressDrawable should not be null');
151+
}
152+
}
153+
154+
helper.buildUIAndRunTest(slider, testAction);
155+
}
156+
157+
export function test_set_linear_gradient_with_stops() {
158+
const slider = new Slider();
159+
160+
// Create a linear gradient with explicit color stops
161+
const gradient = new LinearGradient();
162+
gradient.angle = 0; // 0 degrees (bottom to top)
163+
gradient.colorStops = [
164+
{ color: new Color('orangered'), offset: { unit: '%', value: 0 } },
165+
{ color: new Color('green'), offset: { unit: '%', value: 0.5 } },
166+
{ color: new Color('lightblue'), offset: { unit: '%', value: 1 } },
167+
];
168+
169+
function testAction(views: Array<View>) {
170+
slider.style.backgroundImage = gradient;
171+
172+
// Verify the slider was created
173+
TKUnit.assertNotNull(slider, 'slider should not be null');
174+
175+
if (__APPLE__) {
176+
const minTrackImage = slider.ios.minimumTrackImageForState(UIControlState.Normal);
177+
TKUnit.assertNotNull(minTrackImage, 'minimumTrackImage should be set after applying gradient with stops');
178+
} else if (__ANDROID__) {
179+
const progressDrawable = slider.android.getProgressDrawable();
180+
TKUnit.assertNotNull(progressDrawable, 'progressDrawable should not be null');
181+
}
182+
}
183+
184+
helper.buildUIAndRunTest(slider, testAction);
185+
}
186+
124187
export function test_default_TNS_values() {
125188
const slider = new Slider();
126189
TKUnit.assertEqual(slider.value, 0, 'Default slider.value');

packages/core/ui/slider/index.android.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from '.
33
import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties';
44
import { Color } from '../../color';
55
import { AndroidHelper } from '../core/view';
6+
import { LinearGradient } from '../styling/linear-gradient';
67

78
export * from './slider-common';
89

@@ -142,6 +143,82 @@ export class Slider extends SliderBase {
142143
return null;
143144
}
144145
[backgroundInternalProperty.setNative](value: Background) {
145-
//
146+
if (value && value.image instanceof LinearGradient) {
147+
this._applyGradientToTrack(value.image);
148+
}
149+
}
150+
151+
private _applyGradientToTrack(gradient: LinearGradient): void {
152+
const nativeView = this.nativeViewProtected;
153+
if (!nativeView) {
154+
return;
155+
}
156+
157+
// Create colors array from gradient stops
158+
const colors = Array.create('int', gradient.colorStops.length);
159+
const positions = Array.create('float', gradient.colorStops.length);
160+
let hasPositions = false;
161+
162+
gradient.colorStops.forEach((stop, index) => {
163+
colors[index] = stop.color.android;
164+
if (stop.offset) {
165+
positions[index] = stop.offset.value;
166+
hasPositions = true;
167+
} else {
168+
// Default evenly distributed positions
169+
positions[index] = index / (gradient.colorStops.length - 1);
170+
}
171+
});
172+
173+
// Calculate gradient direction based on angle
174+
const alpha = gradient.angle / (Math.PI * 2);
175+
const startX = Math.pow(Math.sin(Math.PI * (alpha + 0.75)), 2);
176+
const startY = Math.pow(Math.sin(Math.PI * (alpha + 0.5)), 2);
177+
const endX = Math.pow(Math.sin(Math.PI * (alpha + 0.25)), 2);
178+
const endY = Math.pow(Math.sin(Math.PI * alpha), 2);
179+
180+
// Create the shape drawable with gradient
181+
const shape = new android.graphics.drawable.shapes.RectShape();
182+
const shapeDrawable = new android.graphics.drawable.ShapeDrawable(shape);
183+
184+
// We need to set the bounds and shader in a custom callback since the drawable
185+
// doesn't have intrinsic dimensions
186+
const width = nativeView.getWidth() || 1000; // Default width if not yet measured
187+
const height = nativeView.getHeight() || 50; // Default height for progress drawable
188+
189+
const linearGradient = new android.graphics.LinearGradient(startX * width, startY * height, endX * width, endY * height, colors, hasPositions ? positions : null, android.graphics.Shader.TileMode.CLAMP);
190+
191+
shapeDrawable.getPaint().setShader(linearGradient);
192+
shapeDrawable.setBounds(0, 0, width, height);
193+
194+
// Create a layer drawable that wraps the gradient for progress
195+
const progressDrawable = nativeView.getProgressDrawable();
196+
197+
if (progressDrawable instanceof android.graphics.drawable.LayerDrawable) {
198+
// The SeekBar progress drawable is typically a LayerDrawable with 3 layers:
199+
// 0 - background track
200+
// 1 - secondary progress (buffer)
201+
// 2 - progress (filled portion)
202+
const layerDrawable = progressDrawable as android.graphics.drawable.LayerDrawable;
203+
204+
// Create a clip drawable for the progress layer so it clips based on progress
205+
const clipDrawable = new android.graphics.drawable.ClipDrawable(shapeDrawable, android.view.Gravity.LEFT, android.graphics.drawable.ClipDrawable.HORIZONTAL);
206+
207+
// Set the gradient drawable as the progress layer
208+
layerDrawable.setDrawableByLayerId(android.R.id.progress, clipDrawable);
209+
210+
// Also set it as the background track for full gradient visibility
211+
const backgroundShape = new android.graphics.drawable.ShapeDrawable(new android.graphics.drawable.shapes.RectShape());
212+
const bgGradient = new android.graphics.LinearGradient(startX * width, startY * height, endX * width, endY * height, colors, hasPositions ? positions : null, android.graphics.Shader.TileMode.CLAMP);
213+
backgroundShape.getPaint().setShader(bgGradient);
214+
backgroundShape.getPaint().setAlpha(77); // ~30% opacity for background
215+
backgroundShape.setBounds(0, 0, width, height);
216+
layerDrawable.setDrawableByLayerId(android.R.id.background, backgroundShape);
217+
218+
nativeView.setProgressDrawable(layerDrawable);
219+
} else {
220+
// Fallback: just set the shape drawable directly
221+
nativeView.setProgressDrawable(shapeDrawable);
222+
}
146223
}
147224
}

packages/core/ui/slider/index.ios.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from '.
44
import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties';
55
import { Color } from '../../color';
66
import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.';
7+
import { LinearGradient } from '../styling/linear-gradient';
8+
import { Screen } from '../../platform';
79

810
export * from './slider-common';
911

@@ -131,7 +133,106 @@ export class Slider extends SliderBase {
131133
return null;
132134
}
133135
[backgroundInternalProperty.setNative](value: Background) {
134-
//
136+
if (value && value.image instanceof LinearGradient) {
137+
this._applyGradientToTrack(value.image);
138+
}
139+
}
140+
141+
private _applyGradientToTrack(gradient: LinearGradient): void {
142+
const nativeView = this.nativeViewProtected;
143+
if (!nativeView) {
144+
return;
145+
}
146+
147+
// Create a gradient layer
148+
const gradientLayer = CAGradientLayer.new();
149+
150+
// Set up colors from the gradient stops
151+
const iosColors = NSMutableArray.alloc().initWithCapacity(gradient.colorStops.length);
152+
const iosStops = NSMutableArray.alloc<number>().initWithCapacity(gradient.colorStops.length);
153+
let hasStops = false;
154+
155+
gradient.colorStops.forEach((stop, index) => {
156+
iosColors.addObject(stop.color.ios.CGColor);
157+
if (stop.offset) {
158+
iosStops.addObject(stop.offset.value);
159+
hasStops = true;
160+
} else {
161+
// Default evenly distributed positions
162+
iosStops.addObject(index / (gradient.colorStops.length - 1));
163+
}
164+
});
165+
166+
gradientLayer.colors = iosColors;
167+
if (hasStops) {
168+
gradientLayer.locations = iosStops;
169+
}
170+
171+
// Calculate gradient direction based on angle
172+
const alpha = gradient.angle / (Math.PI * 2);
173+
const startX = Math.pow(Math.sin(Math.PI * (alpha + 0.75)), 2);
174+
const startY = Math.pow(Math.sin(Math.PI * (alpha + 0.5)), 2);
175+
const endX = Math.pow(Math.sin(Math.PI * (alpha + 0.25)), 2);
176+
const endY = Math.pow(Math.sin(Math.PI * alpha), 2);
177+
178+
gradientLayer.startPoint = { x: startX, y: startY };
179+
gradientLayer.endPoint = { x: endX, y: endY };
180+
181+
// Create track image from gradient
182+
// Use a reasonable default size for the track
183+
const trackWidth = 200;
184+
const trackHeight = 4;
185+
186+
gradientLayer.frame = CGRectMake(0, 0, trackWidth, trackHeight);
187+
gradientLayer.cornerRadius = trackHeight / 2;
188+
189+
// Render gradient to image
190+
UIGraphicsBeginImageContextWithOptions(CGSizeMake(trackWidth, trackHeight), false, Screen.mainScreen.scale);
191+
const context = UIGraphicsGetCurrentContext();
192+
if (context) {
193+
gradientLayer.renderInContext(context);
194+
const gradientImage = UIGraphicsGetImageFromCurrentImageContext();
195+
UIGraphicsEndImageContext();
196+
197+
if (gradientImage) {
198+
// Create stretchable image for the track
199+
const capInsets = new UIEdgeInsets({
200+
top: 0,
201+
left: trackHeight / 2,
202+
bottom: 0,
203+
right: trackHeight / 2,
204+
});
205+
const stretchableImage = gradientImage.resizableImageWithCapInsetsResizingMode(capInsets, UIImageResizingMode.Stretch);
206+
207+
// Set the gradient image for minimum track (filled portion)
208+
nativeView.setMinimumTrackImageForState(stretchableImage, UIControlState.Normal);
209+
210+
// For maximum track, create a semi-transparent version
211+
UIGraphicsBeginImageContextWithOptions(CGSizeMake(trackWidth, trackHeight), false, Screen.mainScreen.scale);
212+
const maxContext = UIGraphicsGetCurrentContext();
213+
if (maxContext) {
214+
CGContextSetAlpha(maxContext, 0.3);
215+
gradientLayer.renderInContext(maxContext);
216+
const maxTrackImage = UIGraphicsGetImageFromCurrentImageContext();
217+
UIGraphicsEndImageContext();
218+
219+
if (maxTrackImage) {
220+
const maxCapInsets = new UIEdgeInsets({
221+
top: 0,
222+
left: trackHeight / 2,
223+
bottom: 0,
224+
right: trackHeight / 2,
225+
});
226+
const maxStretchableImage = maxTrackImage.resizableImageWithCapInsetsResizingMode(maxCapInsets, UIImageResizingMode.Stretch);
227+
nativeView.setMaximumTrackImageForState(maxStretchableImage, UIControlState.Normal);
228+
}
229+
} else {
230+
UIGraphicsEndImageContext();
231+
}
232+
}
233+
} else {
234+
UIGraphicsEndImageContext();
235+
}
135236
}
136237

137238
private getAccessibilityStep(): number {

0 commit comments

Comments
 (0)