Skip to content
Open
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
63 changes: 63 additions & 0 deletions apps/automated/src/ui/slider/slider-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<View>) {
// 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<View>) {
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);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @omairqazi29 💯 Possible to add a quick sample page which would have a Slider with this style gradient on it in the toolbox?
You can copy/paste this page for example: https://github.com/NativeScript/NativeScript/blob/main/apps/toolbox/src/pages/switch.xml into a sliders.{xml,ts} and add slider to it with this gradient style. It's super helpful to confirm behavior on your end but also for us to confirm as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a quick sample page

Done, PFA, thanks!

image

export function test_default_TNS_values() {
const slider = new Slider();
TKUnit.assertEqual(slider.value, 0, 'Default slider.value');
Expand Down
29 changes: 29 additions & 0 deletions apps/toolbox/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}
1 change: 1 addition & 0 deletions apps/toolbox/src/main-page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<Button text="multiple-scenes" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="root-layout" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="scroll-view" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="sliders" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="status-bar" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="switch" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="tabview" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
Expand Down
14 changes: 14 additions & 0 deletions apps/toolbox/src/pages/sliders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Observable, EventData, Page } from '@nativescript/core';

let page: Page;

export function navigatingTo(args: EventData) {
page = <Page>args.object;
page.bindingContext = new SlidersModel();
}

export class SlidersModel extends Observable {
constructor() {
super();
}
}
48 changes: 48 additions & 0 deletions apps/toolbox/src/pages/sliders.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="Sliders" icon="" class="action-bar">
</ActionBar>
</Page.actionBar>

<GridLayout class="sliders-demo-page">
<ScrollView>
<StackLayout ios:padding="20" visionos:padding="40">

<Label text="Default Slider" class="h3" marginTop="24" />
<Slider minValue="0" maxValue="100" value="50" marginTop="12" />

<Label text="Gradient Slider (CSS)" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, orangered, green, lightblue)" class="caption" />
<Slider class="gradient-slider" minValue="0" maxValue="100" value="50" marginTop="12" />

<Label text="Rainbow Gradient" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)" class="caption" />
<Slider class="rainbow-slider" minValue="0" maxValue="100" value="70" marginTop="12" />

<Label text="Two-Color Gradient" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, #ff6b6b, #4ecdc4)" class="caption" />
<Slider class="two-color-slider" minValue="0" maxValue="100" value="30" marginTop="12" />

<Label text="Sunset Gradient" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, #f12711, #f5af19)" class="caption" />
<Slider class="sunset-slider" minValue="0" maxValue="100" value="60" marginTop="12" />

<Label text="Ocean Gradient" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, #2193b0, #6dd5ed)" class="caption" />
<Slider class="ocean-slider" minValue="0" maxValue="100" value="40" marginTop="12" />

<Label text="Purple Gradient" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, #8e2de2, #4a00e0)" class="caption" />
<Slider class="purple-slider" minValue="0" maxValue="100" value="80" marginTop="12" />

<Label text="Gradient with Color Stops" class="h3" marginTop="24" />
<Label text="background: linear-gradient(to right, red 0%, yellow 50%, green 100%)" class="caption" />
<Slider class="stops-slider" minValue="0" maxValue="100" value="50" marginTop="12" />

<Label text="Disabled Gradient Slider" class="h3" marginTop="24" />
<Slider class="gradient-slider" minValue="0" maxValue="100" value="50" isEnabled="false" marginTop="12" />

</StackLayout>
</ScrollView>
</GridLayout>
</Page>
79 changes: 78 additions & 1 deletion packages/core/ui/slider/index.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
}
}
103 changes: 102 additions & 1 deletion packages/core/ui/slider/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<number>().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 {
Expand Down