Skip to content

Commit ae5717c

Browse files
iveysaurjelbourn
authored andcommitted
feat(slider): support for tick marks (#924)
* Initial drawing of ticks without calculations * Calculate the optimal distance between ticks * Add support for tick interval with numbers * Add comment to perf test * Always add a tick on the end and hide the second to last one if too close * Add tests for tick marks * Comment to clarify tick interval is relative to steps. * Remove fdescribe * Move 30 into a constant for minimum auto tick separation * Remove comment that claims tick interval defaults to auto * Simplify tick interval input * Add missing parenthesis * Use toContain for background gradient tests * Improve comments * Use 'black' rather than '#000000' for tick color * Calculate auto tick separation without loop * Better explain the calculations for the auto tick location. * Merge with NgModules
1 parent d1fdcfa commit ae5717c

File tree

5 files changed

+220
-2
lines changed

5 files changed

+220
-2
lines changed

src/components/slider/slider.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
<div class="md-slider-track-container">
66
<div class="md-slider-track"></div>
77
<div class="md-slider-track md-slider-track-fill"></div>
8+
<div class="md-slider-tick-container"></div>
9+
<div class="md-slider-last-tick-container"></div>
810
</div>
911
<div class="md-slider-thumb-container">
1012
<div class="md-slider-thumb-position">

src/components/slider/slider.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ md-slider *::after {
9595
background-color: md-color($md-accent);
9696
}
9797

98+
.md-slider-tick-container, .md-slider-last-tick-container {
99+
position: absolute;
100+
left: 0;
101+
right: 0;
102+
height: 100%;
103+
}
104+
98105
.md-slider-thumb-container {
99106
position: absolute;
100107
left: 0;

src/components/slider/slider.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ describe('MdSlider', () => {
2525
SliderWithMinAndMax,
2626
SliderWithValue,
2727
SliderWithStep,
28+
SliderWithAutoTickInterval,
29+
SliderWithSetTickInterval
2830
],
2931
});
3032

@@ -434,6 +436,86 @@ describe('MdSlider', () => {
434436
expect(Math.round(trackFillDimensions.width)).toBe(Math.round(thumbPosition));
435437
});
436438
});
439+
440+
describe('slider with auto ticks', () => {
441+
let fixture: ComponentFixture<SliderWithAutoTickInterval>;
442+
let sliderDebugElement: DebugElement;
443+
let sliderNativeElement: HTMLElement;
444+
let tickContainer: HTMLElement;
445+
let lastTickContainer: HTMLElement;
446+
447+
beforeEach(async(() => {
448+
builder.createAsync(SliderWithAutoTickInterval).then(f => {
449+
fixture = f;
450+
fixture.detectChanges();
451+
452+
sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider));
453+
sliderNativeElement = sliderDebugElement.nativeElement;
454+
tickContainer = <HTMLElement>sliderNativeElement.querySelector('.md-slider-tick-container');
455+
lastTickContainer =
456+
<HTMLElement>sliderNativeElement.querySelector('.md-slider-last-tick-container');
457+
});
458+
}));
459+
460+
it('should set the correct tick separation', () => {
461+
// The first tick mark is going to be at value 30 as it is the first step after 30px. The
462+
// width of the slider is 112px because the minimum width is 128px with padding of 8px on
463+
// both sides. The value 30 will be located at the position 33.6px, and 1px is removed from
464+
// the tick mark location in order to center the tick. Therefore, the tick separation should
465+
// be 32.6px.
466+
// toContain is used rather than toBe because FireFox adds 'transparent' to the beginning
467+
// of the background before the repeating linear gradient.
468+
expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' +
469+
'black, black 2px, transparent 2px, transparent 32.6px)');
470+
});
471+
472+
it('should draw a tick mark on the end of the track', () => {
473+
expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, black' +
474+
' 2px, transparent 2px, transparent)');
475+
});
476+
477+
it('should not draw the second to last tick when it is too close to the last tick', () => {
478+
// When the second to last tick is too close (less than half the tick separation) to the last
479+
// one, the tick container width is cut by the tick separation, which removes the second to
480+
// last tick. Since the width of the slider is 112px and the tick separation is 33.6px, the
481+
// tick container width should be 78.4px (112 - 33.6).
482+
expect(tickContainer.style.width).toBe('78.4px');
483+
});
484+
});
485+
486+
describe('slider with set tick interval', () => {
487+
let fixture: ComponentFixture<SliderWithSetTickInterval>;
488+
let sliderDebugElement: DebugElement;
489+
let sliderNativeElement: HTMLElement;
490+
let tickContainer: HTMLElement;
491+
let lastTickContainer: HTMLElement;
492+
493+
beforeEach(async(() => {
494+
builder.createAsync(SliderWithSetTickInterval).then(f => {
495+
fixture = f;
496+
fixture.detectChanges();
497+
498+
sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider));
499+
sliderNativeElement = sliderDebugElement.nativeElement;
500+
tickContainer = <HTMLElement>sliderNativeElement.querySelector('.md-slider-tick-container');
501+
lastTickContainer =
502+
<HTMLElement>sliderNativeElement.querySelector('.md-slider-last-tick-container');
503+
});
504+
}));
505+
506+
it('should set the correct tick separation', () => {
507+
// The slider width is 112px, the first step is at value 18 (step of 3 * tick interval of 6),
508+
// which is at the position 20.16px and 1px is subtracted to center, giving a tick
509+
// separation of 19.16px.
510+
expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' +
511+
'black, black 2px, transparent 2px, transparent 19.16px)');
512+
});
513+
514+
it('should draw a tick mark on the end of the track', () => {
515+
expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, '
516+
+ 'black 2px, transparent 2px, transparent)');
517+
});
518+
});
437519
});
438520

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

565+
@Component({
566+
template: `<md-slider step="5" tick-interval="auto"></md-slider>`
567+
})
568+
class SliderWithAutoTickInterval { }
569+
570+
@Component({
571+
template: `<md-slider step="3" tick-interval="6"></md-slider>`
572+
})
573+
class SliderWithSetTickInterval { }
574+
483575
/**
484576
* Dispatches a click event from an element.
485577
* Note: The mouse event truncates the position for the click.

src/components/slider/slider.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value
1212
import {applyCssTransform} from '@angular2-material/core/style/apply-transform';
1313
import {MdGestureConfig} from '@angular2-material/core/core';
1414

15+
/**
16+
* Visually, a 30px separation between tick marks looks best. This is very subjective but it is
17+
* the default separation we chose.
18+
*/
19+
const MIN_AUTO_TICK_SEPARATION = 30;
20+
1521
@Component({
1622
moduleId: module.id,
1723
selector: 'md-slider',
@@ -53,6 +59,12 @@ export class MdSlider implements AfterContentInit {
5359
/** The values at which the thumb will snap. */
5460
@Input() step: number = 1;
5561

62+
/**
63+
* How often to show ticks. Relative to the step so that a tick always appears on a step.
64+
* Ex: Tick interval of 4 with a step of 3 will draw a tick every 4 steps (every 12 values).
65+
*/
66+
@Input('tick-interval') private _tickInterval: 'auto' | number;
67+
5668
/**
5769
* Whether or not the thumb is sliding.
5870
* Used to determine if there should be a transition for the thumb and fill track.
@@ -122,6 +134,7 @@ export class MdSlider implements AfterContentInit {
122134
ngAfterContentInit() {
123135
this._sliderDimensions = this._renderer.getSliderDimensions();
124136
this.snapToValue();
137+
this._updateTickSeparation();
125138
}
126139

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

192205
/**
@@ -198,7 +211,7 @@ export class MdSlider implements AfterContentInit {
198211

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

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

233+
/**
234+
* Calculates the separation in pixels of tick marks. If there is no tick interval or the interval
235+
* is set to something other than a number or 'auto', nothing happens.
236+
*/
237+
private _updateTickSeparation() {
238+
if (this._tickInterval == 'auto') {
239+
this._updateAutoTickSeparation();
240+
} else if (Number(this._tickInterval)) {
241+
this._updateTickSeparationFromInterval();
242+
}
243+
}
244+
245+
/**
246+
* Calculates the optimal separation in pixels of tick marks based on the minimum auto tick
247+
* separation constant.
248+
*/
249+
private _updateAutoTickSeparation() {
250+
// We're looking for the multiple of step for which the separation between is greater than the
251+
// minimum tick separation.
252+
let sliderWidth = this._sliderDimensions.width;
253+
254+
// This is the total "width" of the slider in terms of values.
255+
let valueWidth = this.max - this.min;
256+
257+
// Calculate how many values exist within 1px on the slider.
258+
let valuePerPixel = valueWidth / sliderWidth;
259+
260+
// Calculate how many values exist in the minimum tick separation (px).
261+
let valuePerSeparation = valuePerPixel * MIN_AUTO_TICK_SEPARATION;
262+
263+
// Calculate how many steps exist in this separation. This will be the lowest value you can
264+
// multiply step by to get a separation that is greater than or equal to the minimum tick
265+
// separation.
266+
let stepsPerSeparation = Math.ceil(valuePerSeparation / this.step);
267+
268+
// Get the percentage of the slider for which this tick would be located so we can then draw
269+
// it on the slider.
270+
let tickPercentage = this.calculatePercentage((this.step * stepsPerSeparation) + this.min);
271+
272+
// The pixel value of the tick is the percentage * the width of the slider. Use this to draw
273+
// the ticks on the slider.
274+
this._renderer.drawTicks(sliderWidth * tickPercentage);
275+
}
276+
277+
/**
278+
* Calculates the separation of tick marks by finding the pixel value of the tickInterval.
279+
*/
280+
private _updateTickSeparationFromInterval() {
281+
// Force tickInterval to be a number so it can be used in calculations.
282+
let interval: number = <number> this._tickInterval;
283+
// Calculate the first value a tick will be located at by getting the step at which the interval
284+
// lands and adding that to the min.
285+
let tickValue = (this.step * interval) + this.min;
286+
287+
// The percentage of the step on the slider is needed in order to calculate the pixel offset
288+
// from the beginning of the slider. This offset is the tick separation.
289+
let tickPercentage = this.calculatePercentage(tickValue);
290+
this._renderer.drawTicks(this._sliderDimensions.width * tickPercentage);
291+
}
292+
293+
/**
294+
* Calculates the percentage of the slider that a value is.
295+
*/
296+
calculatePercentage(value: number) {
297+
return (value - this.min) / (this.max - this.min);
298+
}
299+
300+
/**
301+
* Calculates the value a percentage of the slider corresponds to.
302+
*/
303+
calculateValue(percentage: number) {
304+
return this.min + (percentage * (this.max - this.min));
305+
}
306+
220307
/**
221308
* Return a number between two numbers.
222309
*/
@@ -267,6 +354,32 @@ export class SliderRenderer {
267354
addFocus() {
268355
this._sliderElement.focus();
269356
}
357+
358+
/**
359+
* Draws ticks onto the tick container.
360+
*/
361+
drawTicks(tickSeparation: number) {
362+
let tickContainer = <HTMLElement>this._sliderElement.querySelector('.md-slider-tick-container');
363+
let tickContainerWidth = tickContainer.getBoundingClientRect().width;
364+
// An extra element for the last tick is needed because the linear gradient cannot be told to
365+
// always draw a tick at the end of the gradient. To get around this, there is a second
366+
// container for ticks that has a single tick mark on the very right edge.
367+
let lastTickContainer =
368+
<HTMLElement>this._sliderElement.querySelector('.md-slider-last-tick-container');
369+
// Subtract 1 from the tick separation to center the tick.
370+
// TODO: Evaluate the rendering performance of using repeating background gradients.
371+
tickContainer.style.background = `repeating-linear-gradient(to right, black, black 2px, ` +
372+
`transparent 2px, transparent ${tickSeparation - 1}px)`;
373+
// Add a tick to the very end by starting on the right side and adding a 2px black line.
374+
lastTickContainer.style.background = `linear-gradient(to left, black, black 2px, transparent ` +
375+
`2px, transparent)`;
376+
377+
// If the second to last tick is too close (a separation of less than half the normal
378+
// separation), don't show it by decreasing the width of the tick container element.
379+
if (tickContainerWidth % tickSeparation < (tickSeparation / 2)) {
380+
tickContainer.style.width = tickContainerWidth - tickSeparation + 'px';
381+
}
382+
}
270383
}
271384

272385
export const MD_SLIDER_DIRECTIVES = [MdSlider];

src/demo-app/slider/slider-demo.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ <h1>Slider with step defined</h1>
2626
<md-slider min="1" max="100" step="20" #slider5></md-slider>
2727
{{slider5.value}}
2828
</section>
29+
30+
<h1>Slider with set tick interval</h1>
31+
<md-slider tick-interval="auto"></md-slider>
32+
<md-slider tick-interval="9"></md-slider>

0 commit comments

Comments
 (0)