Skip to content

Add support for drawing tick marks on the slider #924

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 8, 2016
Merged
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
2 changes: 2 additions & 0 deletions src/components/slider/slider.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<div class="md-slider-track-container">
<div class="md-slider-track"></div>
<div class="md-slider-track md-slider-track-fill"></div>
<div class="md-slider-tick-container"></div>
<div class="md-slider-last-tick-container"></div>
</div>
<div class="md-slider-thumb-container">
<div class="md-slider-thumb-position">
Expand Down
7 changes: 7 additions & 0 deletions src/components/slider/slider.scss
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ md-slider *::after {
background-color: md-color($md-accent);
}

.md-slider-tick-container, .md-slider-last-tick-container {
position: absolute;
left: 0;
right: 0;
height: 100%;
}

.md-slider-thumb-container {
position: absolute;
left: 0;
Expand Down
92 changes: 92 additions & 0 deletions src/components/slider/slider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe('MdSlider', () => {
SliderWithMinAndMax,
SliderWithValue,
SliderWithStep,
SliderWithAutoTickInterval,
SliderWithSetTickInterval
],
});

Expand Down Expand Up @@ -434,6 +436,86 @@ describe('MdSlider', () => {
expect(Math.round(trackFillDimensions.width)).toBe(Math.round(thumbPosition));
});
});

describe('slider with auto ticks', () => {
let fixture: ComponentFixture<SliderWithAutoTickInterval>;
let sliderDebugElement: DebugElement;
let sliderNativeElement: HTMLElement;
let tickContainer: HTMLElement;
let lastTickContainer: HTMLElement;

beforeEach(async(() => {
builder.createAsync(SliderWithAutoTickInterval).then(f => {
fixture = f;
fixture.detectChanges();

sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider));
sliderNativeElement = sliderDebugElement.nativeElement;
tickContainer = <HTMLElement>sliderNativeElement.querySelector('.md-slider-tick-container');
lastTickContainer =
<HTMLElement>sliderNativeElement.querySelector('.md-slider-last-tick-container');
});
}));

it('should set the correct tick separation', () => {
// The first tick mark is going to be at value 30 as it is the first step after 30px. The
// width of the slider is 112px because the minimum width is 128px with padding of 8px on
// both sides. The value 30 will be located at the position 33.6px, and 1px is removed from
// the tick mark location in order to center the tick. Therefore, the tick separation should
// be 32.6px.
// toContain is used rather than toBe because FireFox adds 'transparent' to the beginning
// of the background before the repeating linear gradient.
expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' +
Copy link
Member

Choose a reason for hiding this comment

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

Also explain why you use toContain rather than toBe

'black, black 2px, transparent 2px, transparent 32.6px)');
});

it('should draw a tick mark on the end of the track', () => {
expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, black' +
' 2px, transparent 2px, transparent)');
});

it('should not draw the second to last tick when it is too close to the last tick', () => {
// When the second to last tick is too close (less than half the tick separation) to the last
// one, the tick container width is cut by the tick separation, which removes the second to
// last tick. Since the width of the slider is 112px and the tick separation is 33.6px, the
// tick container width should be 78.4px (112 - 33.6).
expect(tickContainer.style.width).toBe('78.4px');
});
});

describe('slider with set tick interval', () => {
let fixture: ComponentFixture<SliderWithSetTickInterval>;
let sliderDebugElement: DebugElement;
let sliderNativeElement: HTMLElement;
let tickContainer: HTMLElement;
let lastTickContainer: HTMLElement;

beforeEach(async(() => {
builder.createAsync(SliderWithSetTickInterval).then(f => {
fixture = f;
fixture.detectChanges();

sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider));
sliderNativeElement = sliderDebugElement.nativeElement;
tickContainer = <HTMLElement>sliderNativeElement.querySelector('.md-slider-tick-container');
lastTickContainer =
<HTMLElement>sliderNativeElement.querySelector('.md-slider-last-tick-container');
});
}));

it('should set the correct tick separation', () => {
// The slider width is 112px, the first step is at value 18 (step of 3 * tick interval of 6),
// which is at the position 20.16px and 1px is subtracted to center, giving a tick
// separation of 19.16px.
expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' +
'black, black 2px, transparent 2px, transparent 19.16px)');
});

it('should draw a tick mark on the end of the track', () => {
expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, '
+ 'black 2px, transparent 2px, transparent)');
});
});
});

// The transition has to be removed in order to test the updated positions without setTimeout.
Expand Down Expand Up @@ -480,6 +562,16 @@ class SliderWithValue { }
})
class SliderWithStep { }

@Component({
template: `<md-slider step="5" tick-interval="auto"></md-slider>`
})
class SliderWithAutoTickInterval { }

@Component({
template: `<md-slider step="3" tick-interval="6"></md-slider>`
})
class SliderWithSetTickInterval { }

/**
* Dispatches a click event from an element.
* Note: The mouse event truncates the position for the click.
Expand Down
117 changes: 115 additions & 2 deletions src/components/slider/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value
import {applyCssTransform} from '@angular2-material/core/style/apply-transform';
import {MdGestureConfig} from '@angular2-material/core/core';

/**
* Visually, a 30px separation between tick marks looks best. This is very subjective but it is
* the default separation we chose.
*/
const MIN_AUTO_TICK_SEPARATION = 30;

@Component({
moduleId: module.id,
selector: 'md-slider',
Expand Down Expand Up @@ -53,6 +59,12 @@ export class MdSlider implements AfterContentInit {
/** The values at which the thumb will snap. */
@Input() step: number = 1;

/**
* How often to show ticks. Relative to the step so that a tick always appears on a step.
* Ex: Tick interval of 4 with a step of 3 will draw a tick every 4 steps (every 12 values).
*/
@Input('tick-interval') private _tickInterval: 'auto' | number;

/**
* Whether or not the thumb is sliding.
* Used to determine if there should be a transition for the thumb and fill track.
Expand Down Expand Up @@ -122,6 +134,7 @@ export class MdSlider implements AfterContentInit {
ngAfterContentInit() {
this._sliderDimensions = this._renderer.getSliderDimensions();
this.snapToValue();
this._updateTickSeparation();
}

/** TODO: internal */
Expand Down Expand Up @@ -186,7 +199,7 @@ export class MdSlider implements AfterContentInit {
* This is also used to move the thumb to a snapped value once sliding is done.
*/
updatePercentFromValue() {
this._percent = (this.value - this.min) / (this.max - this.min);
this._percent = this.calculatePercentage(this.value);
}

/**
Expand All @@ -198,7 +211,7 @@ export class MdSlider implements AfterContentInit {

// The exact value is calculated from the event and used to find the closest snap value.
this._percent = this.clamp((pos - offset) / size);
let exactValue = this.min + (this._percent * (this.max - this.min));
let exactValue = this.calculateValue(this._percent);

// This calculation finds the closest step by finding the closest whole number divisible by the
// step relative to the min.
Expand All @@ -217,6 +230,80 @@ export class MdSlider implements AfterContentInit {
this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width);
}

/**
* Calculates the separation in pixels of tick marks. If there is no tick interval or the interval
* is set to something other than a number or 'auto', nothing happens.
*/
Copy link
Member

Choose a reason for hiding this comment

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

This function description should just be something like "Calculates the separation in pixels of tick marks...". The fact that it delegates to another function is not something you need to mention in the description.

private _updateTickSeparation() {
if (this._tickInterval == 'auto') {
this._updateAutoTickSeparation();
} else if (Number(this._tickInterval)) {
this._updateTickSeparationFromInterval();
}
}

/**
* Calculates the optimal separation in pixels of tick marks based on the minimum auto tick
* separation constant.
*/
private _updateAutoTickSeparation() {
// We're looking for the multiple of step for which the separation between is greater than the
// minimum tick separation.
let sliderWidth = this._sliderDimensions.width;

// This is the total "width" of the slider in terms of values.
let valueWidth = this.max - this.min;

// Calculate how many values exist within 1px on the slider.
let valuePerPixel = valueWidth / sliderWidth;

// Calculate how many values exist in the minimum tick separation (px).
let valuePerSeparation = valuePerPixel * MIN_AUTO_TICK_SEPARATION;

// Calculate how many steps exist in this separation. This will be the lowest value you can
// multiply step by to get a separation that is greater than or equal to the minimum tick
// separation.
let stepsPerSeparation = Math.ceil(valuePerSeparation / this.step);

// Get the percentage of the slider for which this tick would be located so we can then draw
// it on the slider.
let tickPercentage = this.calculatePercentage((this.step * stepsPerSeparation) + this.min);

// The pixel value of the tick is the percentage * the width of the slider. Use this to draw
// the ticks on the slider.
this._renderer.drawTicks(sliderWidth * tickPercentage);
}

/**
* Calculates the separation of tick marks by finding the pixel value of the tickInterval.
*/
private _updateTickSeparationFromInterval() {
// Force tickInterval to be a number so it can be used in calculations.
let interval: number = <number> this._tickInterval;
// Calculate the first value a tick will be located at by getting the step at which the interval
// lands and adding that to the min.
let tickValue = (this.step * interval) + this.min;

// The percentage of the step on the slider is needed in order to calculate the pixel offset
// from the beginning of the slider. This offset is the tick separation.
let tickPercentage = this.calculatePercentage(tickValue);
this._renderer.drawTicks(this._sliderDimensions.width * tickPercentage);
}

/**
* Calculates the percentage of the slider that a value is.
*/
calculatePercentage(value: number) {
return (value - this.min) / (this.max - this.min);
}

/**
* Calculates the value a percentage of the slider corresponds to.
*/
calculateValue(percentage: number) {
return this.min + (percentage * (this.max - this.min));
}

/**
* Return a number between two numbers.
*/
Expand Down Expand Up @@ -267,6 +354,32 @@ export class SliderRenderer {
addFocus() {
this._sliderElement.focus();
}

/**
* Draws ticks onto the tick container.
*/
drawTicks(tickSeparation: number) {
let tickContainer = <HTMLElement>this._sliderElement.querySelector('.md-slider-tick-container');
let tickContainerWidth = tickContainer.getBoundingClientRect().width;
// An extra element for the last tick is needed because the linear gradient cannot be told to
// always draw a tick at the end of the gradient. To get around this, there is a second
// container for ticks that has a single tick mark on the very right edge.
let lastTickContainer =
Copy link
Member

Choose a reason for hiding this comment

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

Add comment explaining why you need an extra element for the last tick.

<HTMLElement>this._sliderElement.querySelector('.md-slider-last-tick-container');
// Subtract 1 from the tick separation to center the tick.
// TODO: Evaluate the rendering performance of using repeating background gradients.
tickContainer.style.background = `repeating-linear-gradient(to right, black, black 2px, ` +
`transparent 2px, transparent ${tickSeparation - 1}px)`;
// Add a tick to the very end by starting on the right side and adding a 2px black line.
lastTickContainer.style.background = `linear-gradient(to left, black, black 2px, transparent ` +
`2px, transparent)`;

// If the second to last tick is too close (a separation of less than half the normal
// separation), don't show it by decreasing the width of the tick container element.
if (tickContainerWidth % tickSeparation < (tickSeparation / 2)) {
tickContainer.style.width = tickContainerWidth - tickSeparation + 'px';
}
}
}

export const MD_SLIDER_DIRECTIVES = [MdSlider];
Expand Down
4 changes: 4 additions & 0 deletions src/demo-app/slider/slider-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ <h1>Slider with step defined</h1>
<md-slider min="1" max="100" step="20" #slider5></md-slider>
{{slider5.value}}
</section>

<h1>Slider with set tick interval</h1>
<md-slider tick-interval="auto"></md-slider>
<md-slider tick-interval="9"></md-slider>