Skip to content

Commit

Permalink
feat(slider): support for tick marks (#924)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
iveysaur authored and jelbourn committed Aug 8, 2016
1 parent d1fdcfa commit ae5717c
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 2 deletions.
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, ' +
'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.
*/
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 =
<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>

0 comments on commit ae5717c

Please sign in to comment.