refactor(range): move logic to stateless

- float steps are possible
- improved performance
- state is in a single place
- fix when value changes dynamically
- fix jumpy gesture
This commit is contained in:
Manu Mtz.-Almeida
2018-05-10 14:51:45 +02:00
parent e352d1b69b
commit 4191637f9f
8 changed files with 226 additions and 314 deletions

View File

@ -42,6 +42,7 @@ import {
GestureConfig, GestureConfig,
GestureDetail, GestureDetail,
InputChangeEvent, InputChangeEvent,
Knob,
LoadingOptions, LoadingOptions,
Menu, Menu,
MenuChangeEventDetail, MenuChangeEventDetail,
@ -4494,14 +4495,14 @@ declare global {
namespace StencilComponents { namespace StencilComponents {
interface IonRangeKnob { interface IonRangeKnob {
'disabled': boolean; 'disabled': boolean;
'knob': string; 'knob': Knob;
'labelId': string; 'labelId': string;
'max': number; 'max': number;
'min': number; 'min': number;
'pin': boolean; 'pin': boolean;
'pressed': boolean; 'pressed': boolean;
'ratio': number; 'ratio': number;
'val': number; 'value': number;
} }
} }
@ -4525,7 +4526,7 @@ declare global {
namespace JSXElements { namespace JSXElements {
export interface IonRangeKnobAttributes extends HTMLAttributes { export interface IonRangeKnobAttributes extends HTMLAttributes {
'disabled'?: boolean; 'disabled'?: boolean;
'knob'?: string; 'knob'?: Knob;
'labelId'?: string; 'labelId'?: string;
'max'?: number; 'max'?: number;
'min'?: number; 'min'?: number;
@ -4534,7 +4535,7 @@ declare global {
'pin'?: boolean; 'pin'?: boolean;
'pressed'?: boolean; 'pressed'?: boolean;
'ratio'?: number; 'ratio'?: number;
'val'?: number; 'value'?: number;
} }
} }
} }
@ -4580,14 +4581,6 @@ declare global {
* If true, a pin with integer value is shown when the knob is pressed. Defaults to `false`. * If true, a pin with integer value is shown when the knob is pressed. Defaults to `false`.
*/ */
'pin': boolean; 'pin': boolean;
/**
* Returns the ratio of the knob's is current location, which is a number between `0` and `1`. If two knobs are used, this property represents the lower value.
*/
'ratio': () => number;
/**
* Returns the ratio of the upper value's is current location, which is a number between `0` and `1`. If there is only one knob, then this will return `null`.
*/
'ratioUpper': () => number | null;
/** /**
* If true, the knob snaps to tick marks evenly spaced based on the step property value. Defaults to `false`. * If true, the knob snaps to tick marks evenly spaced based on the step property value. Defaults to `false`.
*/ */

View File

@ -1,19 +1,20 @@
import { Component, Event, EventEmitter, Listen, Prop } from '@stencil/core'; import { Component, Event, EventEmitter, Listen, Prop } from '@stencil/core';
import { Knob } from '../../interface';
@Component({ @Component({
tag: `ion-range-knob` tag: `ion-range-knob`
}) })
export class RangeKnob { export class RangeKnob {
@Prop() pressed = false; @Prop() pressed!: boolean;
@Prop() pin = false; @Prop() pin!: boolean;
@Prop() min!: number; @Prop() min!: number;
@Prop() max!: number; @Prop() max!: number;
@Prop() val!: number; @Prop() value!: number;
@Prop() disabled = false;
@Prop() labelId!: string;
@Prop() knob!: string;
@Prop() ratio!: number; @Prop() ratio!: number;
@Prop() disabled!: boolean;
@Prop() labelId!: string;
@Prop() knob!: Knob;
@Event() ionIncrease!: EventEmitter; @Event() ionIncrease!: EventEmitter;
@Event() ionDecrease!: EventEmitter; @Event() ionDecrease!: EventEmitter;
@ -25,6 +26,7 @@ export class RangeKnob {
this.ionDecrease.emit({isIncrease: false, knob: this.knob}); this.ionDecrease.emit({isIncrease: false, knob: this.knob});
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} else if (keyCode === KEY_RIGHT || keyCode === KEY_UP) { } else if (keyCode === KEY_RIGHT || keyCode === KEY_UP) {
this.ionIncrease.emit({isIncrease: true, knob: this.knob}); this.ionIncrease.emit({isIncrease: true, knob: this.knob});
ev.preventDefault(); ev.preventDefault();
@ -32,34 +34,33 @@ export class RangeKnob {
} }
} }
leftPos(val: number) {
return `${val * 100}%`;
}
hostData() { hostData() {
const {value, min, max} = this;
const pos = this.ratio * 100;
return { return {
class: { class: {
'range-knob-handle': true,
'range-knob-pressed': this.pressed, 'range-knob-pressed': this.pressed,
'range-knob-min': this.val === this.min || this.val === undefined, 'range-knob-min': value === min,
'range-knob-max': this.val === this.max 'range-knob-max': value === max
}, },
style: { style: {
'left': this.leftPos(this.ratio) 'left': `${pos}%`
}, },
'role': 'slider', 'role': 'slider',
'tabindex': this.disabled ? -1 : 0, 'tabindex': this.disabled ? -1 : 0,
'aria-valuemin': this.min, 'aria-valuemin': min,
'aria-valuemax': this.max, 'aria-valuemax': max,
'aria-disabled': this.disabled, 'aria-disabled': this.disabled,
'aria-labelledby': this.labelId, 'aria-labelledby': this.labelId,
'aria-valuenow': this.val 'aria-valuenow': value
}; };
} }
render() { render() {
if (this.pin) { if (this.pin) {
return [ return [
<div class="range-pin" role="presentation">{this.val}</div>, <div class="range-pin" role="presentation">{Math.round(this.value)}</div>,
<div class="range-knob" role="presentation" /> <div class="range-knob" role="presentation" />
]; ];
} }

View File

@ -14,7 +14,7 @@ boolean
#### knob #### knob
string number
#### labelId #### labelId
@ -47,7 +47,7 @@ boolean
number number
#### val #### value
number number
@ -61,7 +61,7 @@ boolean
#### knob #### knob
string number
#### label-id #### label-id
@ -94,7 +94,7 @@ boolean
number number
#### val #### value
number number

View File

@ -0,0 +1,13 @@
export const enum Knob {
None,
A,
B
}
export type RangeValue = number | {lower: number, upper: number};
export interface RangeEventDetail extends Event {
isIncrease: boolean;
knob: Knob;
}

View File

@ -1,12 +1,7 @@
import { Component, Element, Event, EventEmitter, Listen, Method, Prop, State, Watch } from '@stencil/core'; import { Component, Element, Event, EventEmitter, Listen, Prop, State, Watch } from '@stencil/core';
import { BaseInput, GestureDetail, Mode, RangeInputChangeEvent, StyleEvent } from '../../interface'; import { BaseInput, GestureDetail, Mode, RangeInputChangeEvent, StyleEvent } from '../../interface';
import { clamp, debounceEvent, deferEvent } from '../../utils/helpers'; import { clamp, debounceEvent, deferEvent } from '../../utils/helpers';
import { Knob, RangeEventDetail, RangeValue } from './range-interface';
export interface Tick {
ratio: number | (() => number);
left: string;
active?: boolean;
}
@Component({ @Component({
tag: 'ion-range', tag: 'ion-range',
@ -20,24 +15,15 @@ export interface Tick {
}) })
export class Range implements BaseInput { export class Range implements BaseInput {
hasFocus = false; private noUpdate = false;
private rect!: ClientRect;
private hasFocus = false;
@Element() el!: HTMLElement; @Element() el!: HTMLStencilElement;
@State() barL!: string;
@State() barR!: string;
@State() valA = 0;
@State() valB = 0;
@State() ratioA = 0;
@State() ratioB = 0;
@State() ticks: Tick[] = [];
@State() activeB = false;
@State() rect!: ClientRect;
@State() pressed = false;
@State() pressedA = false;
@State() pressedB = false;
@State() private ratioA = 0;
@State() private ratioB = 0;
@State() private pressedKnob = Knob.None;
/** /**
* The color to use from your Sass `$colors` map. * The color to use from your Sass `$colors` map.
@ -74,16 +60,16 @@ export class Range implements BaseInput {
*/ */
@Prop() dualKnobs = false; @Prop() dualKnobs = false;
/**
* Maximum integer value of the range. Defaults to `100`.
*/
@Prop() max = 100;
/** /**
* Minimum integer value of the range. Defaults to `0`. * Minimum integer value of the range. Defaults to `0`.
*/ */
@Prop() min = 0; @Prop() min = 0;
/**
* Maximum integer value of the range. Defaults to `100`.
*/
@Prop() max = 100;
/** /**
* If true, a pin with integer value is shown when the knob * If true, a pin with integer value is shown when the knob
* is pressed. Defaults to `false`. * is pressed. Defaults to `false`.
@ -113,11 +99,12 @@ export class Range implements BaseInput {
/** /**
* the value of the range. * the value of the range.
*/ */
@Prop({ mutable: true }) value: any; @Prop({ mutable: true }) value: any = 0;
@Watch('value') @Watch('value')
protected valueChanged(value: any) { protected valueChanged(value: RangeValue) {
this.inputUpdated(); if (!this.noUpdate) {
this.emitStyle(); this.updateRatio();
}
this.ionChange.emit({value}); this.ionChange.emit({value});
} }
@ -146,12 +133,45 @@ export class Range implements BaseInput {
componentWillLoad() { componentWillLoad() {
this.ionStyle = deferEvent(this.ionStyle); this.ionStyle = deferEvent(this.ionStyle);
this.inputUpdated(); this.updateRatio();
this.createTicks();
this.debounceChanged(); this.debounceChanged();
this.emitStyle(); this.emitStyle();
} }
@Listen('ionIncrease')
@Listen('ionDecrease')
keyChng(ev: CustomEvent<RangeEventDetail>) {
let step = this.step;
step = step > 0 ? step : 1;
step = step / (this.max - this.min);
if (!ev.detail.isIncrease) {
step *= -1;
}
if (ev.detail.knob === Knob.A) {
this.ratioA += step;
} else {
this.ratioB += step;
}
}
private getValue(): RangeValue {
const value = this.value || 0;
if (this.dualKnobs) {
if (typeof value === 'object') {
return value;
}
return {
lower: 0,
upper: value,
};
} else {
if (typeof value === 'object') {
return value.upper;
}
return value;
}
}
private emitStyle() { private emitStyle() {
this.ionStyle.emit({ this.ionStyle.emit({
'range-disabled': this.disabled 'range-disabled': this.disabled
@ -174,244 +194,130 @@ export class Range implements BaseInput {
} }
} }
private inputUpdated() { private onDragStart(detail: GestureDetail) {
const val = this.value; this.fireFocus();
if (this.dualKnobs) {
this.valA = val.lower; const el = this.el.querySelector('.range-slider')!;
this.valB = val.upper; const rect = this.rect = el.getBoundingClientRect() as any;
this.ratioA = this.valueToRatio(val.lower); const currentX = detail.currentX;
this.ratioB = this.valueToRatio(val.upper);
} else { // figure out which knob they started closer to
this.valA = val; const ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
this.ratioA = this.valueToRatio(val); this.pressedKnob = (!this.dualKnobs || (Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio)))
} ? Knob.A
this.updateBar(); : Knob.B;
// update the active knob's position
this.update(currentX);
} }
private updateBar() { private onDragMove(detail: GestureDetail) {
const ratioA = this.ratioA; this.update(detail.currentX);
const ratioB = this.ratioB;
if (this.dualKnobs) {
this.barL = `${Math.min(ratioA, ratioB) * 100}%`;
this.barR = `${100 - Math.max(ratioA, ratioB) * 100}%`;
} else {
this.barL = '';
this.barR = `${100 - ratioA * 100}%`;
}
this.updateTicks();
} }
private createTicks() { private onDragEnd(detail: GestureDetail) {
if (this.snaps) { this.update(detail.currentX);
for (let value = this.min; value <= this.max; value += this.step) { this.pressedKnob = Knob.None;
const ratio = this.valueToRatio(value); this.fireBlur();
this.ticks.push({
ratio,
left: `${ratio * 100}%`
});
}
this.updateTicks();
}
} }
private updateTicks() { private update(currentX: number) {
const ticks = this.ticks;
const ratio = this.ratio;
if (this.snaps && ticks) {
if (this.dualKnobs) {
const upperRatio = this.ratioUpper()!;
ticks.forEach(t => {
t.active = t.ratio >= ratio && t.ratio <= upperRatio;
});
} else {
ticks.forEach(t => {
t.active = t.ratio <= ratio;
});
}
}
}
private valueToRatio(value: number) {
value = Math.round((value - this.min) / this.step) * this.step;
value = value / (this.max - this.min);
return clamp(0, value, 1);
}
private ratioToValue(ratio: number) {
ratio = Math.round((this.max - this.min) * ratio);
ratio = Math.round(ratio / this.step) * this.step + this.min;
return clamp(this.min, ratio, this.max);
}
// private inputNormalize(val: any): any {
// if (this.dualKnobs) {
// return val;
// } else {
// val = parseFloat(val);
// return isNaN(val) ? undefined : val;
// }
// }
private update(current: { x: number; y: number }, rect: ClientRect, isPressed: boolean) {
// figure out where the pointer is currently at // figure out where the pointer is currently at
// update the knob being interacted with // update the knob being interacted with
let ratio = clamp(0, (current.x - rect.left) / rect.width, 1); const rect = this.rect;
const val = this.ratioToValue(ratio); let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
if (this.snaps) { if (this.snaps) {
// snaps the ratio to the current value // snaps the ratio to the current value
ratio = this.valueToRatio(val); const value = ratioToValue(ratio, this.min, this.max, this.step);
ratio = valueToRatio(value, this.min, this.max);
} }
// update which knob is pressed // update which knob is pressed
this.pressed = isPressed; if (this.pressedKnob === Knob.A) {
let valChanged = false;
if (this.activeB) {
// when the pointer down started it was determined
// that knob B was the one they were interacting with
this.pressedB = isPressed;
this.pressedA = false;
this.ratioB = ratio;
valChanged = val === this.valB;
this.valB = val;
} else {
// interacting with knob A
this.pressedA = isPressed;
this.pressedB = false;
this.ratioA = ratio; this.ratioA = ratio;
valChanged = val === this.valA;
this.valA = val;
}
this.updateBar();
if (valChanged) {
return false;
}
// value has been updated
let value;
if (this.dualKnobs) {
// dual knobs have an lower and upper value
value = {
lower: Math.min(this.valA, this.valB),
upper: Math.max(this.valA, this.valB)
};
} else { } else {
// single knob only has one value this.ratioB = ratio;
value = this.valA;
} }
// Update input value // Update input value
this.value = value; this.updateValue();
return true;
} }
/** private get valA() {
* Returns the ratio of the knob's is current location, which is a number return ratioToValue(this.ratioA, this.min, this.max, this.step);
* between `0` and `1`. If two knobs are used, this property represents }
* the lower value.
*/ private get valB() {
@Method() return ratioToValue(this.ratioB, this.min, this.max, this.step);
ratio(): number { }
private get ratioLower() {
if (this.dualKnobs) { if (this.dualKnobs) {
return Math.min(this.ratioA, this.ratioB); return Math.min(this.ratioA, this.ratioB);
} }
return 0;
}
private get ratioUpper() {
if (this.dualKnobs) {
return Math.max(this.ratioA, this.ratioB);
}
return this.ratioA; return this.ratioA;
} }
/** private updateRatio() {
* Returns the ratio of the upper value's is current location, which is const value = this.getValue() as any;
* a number between `0` and `1`. If there is only one knob, then this const {min, max} = this;
* will return `null`.
*/
@Method()
ratioUpper() {
if (this.dualKnobs) { if (this.dualKnobs) {
return Math.max(this.ratioA, this.ratioB); this.ratioA = valueToRatio(value.lower, min, max);
} this.ratioB = valueToRatio(value.upper, min, max);
return null;
}
@Listen('ionIncrease, ionDecrease')
keyChng(ev: RangeEvent) {
const step = this.step;
if (ev.detail.knob === 'knobB') {
if (ev.detail.isIncrease) {
this.valB += step;
} else {
this.valB -= step;
}
this.valB = clamp(this.min, this.valB, this.max);
this.ratioB = this.valueToRatio(this.valB);
} else { } else {
if (ev.detail.isIncrease) { this.ratioA = valueToRatio(value, min, max);
this.valA += step;
} else {
this.valA -= step;
}
this.valA = clamp(this.min, this.valA, this.max);
this.ratioA = this.valueToRatio(this.valA);
} }
this.updateBar();
} }
private onDragStart(detail: GestureDetail) { private updateValue() {
if (this.disabled) return false; this.noUpdate = true;
this.fireFocus();
const current = { x: detail.currentX, y: detail.currentY }; const {valA, valB} = this;
const el = this.el.querySelector('.range-slider')!; this.value = (!this.dualKnobs)
const rect = el.getBoundingClientRect(); ? valA
this.rect = rect; : {
lower: Math.min(valA, valB),
upper: Math.max(valA, valB)
};
// figure out which knob they started closer to this.noUpdate = false;
const ratio = clamp(0, (current.x - rect.left) / rect.width, 1);
this.activeB =
this.dualKnobs &&
Math.abs(ratio - this.ratioA) > Math.abs(ratio - this.ratioB);
// update the active knob's position
this.update(current, rect, true);
// return true so the pointer events
// know everything's still valid
return true;
}
private onDragEnd(detail: GestureDetail) {
if (this.disabled) {
return;
}
// update the active knob's position
this.update({ x: detail.currentX, y: detail.currentY }, this.rect, false);
// trigger ionBlur event
this.fireBlur();
}
private onDragMove(detail: GestureDetail) {
if (this.disabled) {
return;
}
const current = { x: detail.currentX, y: detail.currentY };
// update the active knob's position
this.update(current, this.rect, true);
} }
hostData() { hostData() {
return { return {
class: { class: {
'range-disabled': this.disabled, 'range-disabled': this.disabled,
'range-pressed': this.pressed, 'range-pressed': this.pressedKnob !== Knob.None,
'range-has-pin': this.pin 'range-has-pin': this.pin
} }
}; };
} }
render() { render() {
const {min, max, step, ratioLower, ratioUpper} = this;
const barL = `${ratioLower * 100}%`;
const barR = `${100 - ratioUpper * 100}%`;
const ticks = [];
if (this.snaps) {
for (let value = min; value <= max; value += step) {
const ratio = valueToRatio(value, min, max);
ticks.push({
ratio,
active: ratio >= ratioLower && ratio <= ratioUpper,
left: `${ratio * 100}%`
});
}
}
return [ return [
<slot name="start"></slot>, <slot name="start"></slot>,
<ion-gesture <ion-gesture
@ -426,12 +332,14 @@ export class Range implements BaseInput {
threshold={0}> threshold={0}>
<div class="range-slider"> <div class="range-slider">
{this.ticks.map(t => {ticks.map(t =>
<div <div
style={{ left: t.left! }} style={{ left: t.left }}
role="presentation" role="presentation"
class={{ 'range-tick': true, 'range-tick-active': !!t.active }} class={{
/> 'range-tick': true,
'range-tick-active': t.active
}}/>
)} )}
<div class="range-bar" role="presentation" /> <div class="range-bar" role="presentation" />
@ -439,33 +347,28 @@ export class Range implements BaseInput {
class="range-bar range-bar-active" class="range-bar range-bar-active"
role="presentation" role="presentation"
style={{ style={{
left: this.barL, left: barL,
right: this.barR right: barR
}} }}
/> />
<ion-range-knob <ion-range-knob
class="range-knob-handle" knob={Knob.A}
knob="knobA" pressed={this.pressedKnob === Knob.A}
pressed={this.pressedA} value={this.valA}
ratio={this.ratioA} ratio={this.ratioA}
val={this.valA}
pin={this.pin} pin={this.pin}
min={this.min} min={min}
max={this.max} max={max}/>
/>
{this.dualKnobs { this.dualKnobs &&
? <ion-range-knob <ion-range-knob
class="range-knob-handle" knob={Knob.B}
knob="knobB" pressed={this.pressedKnob === Knob.B}
pressed={this.pressedB} value={this.valB}
ratio={this.ratioB} ratio={this.ratioB}
val={this.valB} pin={this.pin}
pin={this.pin} min={min}
min={this.min} max={max} /> }
max={this.max}
/>
: null}
</div> </div>
</ion-gesture>, </ion-gesture>,
<slot name="end"></slot> <slot name="end"></slot>
@ -473,9 +376,15 @@ export class Range implements BaseInput {
} }
} }
export interface RangeEvent extends Event {
detail: { export function ratioToValue(ratio: number, min: number, max: number, step: number): number {
isIncrease: boolean, let value = ((max - min) * ratio);
knob: string if (step > 0) {
}; value = Math.round(value / step) * step + min;
}
return clamp(min, value, max);
}
export function valueToRatio(value: number, min: number, max: number): number {
return clamp(0, (value - min) / (max - min), 1);
} }

View File

@ -254,22 +254,6 @@ Emitted when the range has focus.
Emitted when the styles change. Emitted when the styles change.
## Methods
#### ratio()
Returns the ratio of the knob's is current location, which is a number
between `0` and `1`. If two knobs are used, this property represents
the lower value.
#### ratioUpper()
Returns the ratio of the upper value's is current location, which is
a number between `0` and `1`. If there is only one knob, then this
will return `null`.
---------------------------------------------- ----------------------------------------------

View File

@ -26,10 +26,10 @@
<ion-range value="20"></ion-range> <ion-range value="20"></ion-range>
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-range value="60" color="light"></ion-range> <ion-range value="60" color="light" step="10" pin="true"></ion-range>
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-range value="80" color="dark"></ion-range> <ion-range value="80" color="dark" step="10" snaps="true" pin="true"></ion-range>
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-range pin="true" color="secondary" value="86"> <ion-range pin="true" color="secondary" value="86">
@ -48,7 +48,7 @@
Dynamic Value Dynamic Value
</ion-list-header> </ion-list-header>
<ion-item> <ion-item>
<ion-range pin="true" color="secondary" id="progressValue"></ion-range> <ion-range pin="true" step="0" color="secondary" id="progressValue"></ion-range>
<div id="progressValueResult" slot="end"></div> <div id="progressValueResult" slot="end"></div>
</ion-item> </ion-item>
<ion-item> <ion-item>
@ -103,21 +103,32 @@
</ion-app> </ion-app>
<script> <script>
var ranges = ['progressValue', 'brightnessValue', 'contrastValue']; const progressValue = document.getElementById('progressValue');
const brightnessValue = document.getElementById('brightnessValue');
const contrastValue = document.getElementById('contrastValue');
const ranges = [progressValue, brightnessValue, contrastValue];
for (var i = 0; i < ranges.length; i++) { for (var i = 0; i < ranges.length; i++) {
var el = document.getElementById(ranges[i]); var el = ranges[i];
el.value = (i + 1) * 10; el.value = (i + 1) * 10;
updateRangeResult(el); updateRangeResult(el);
el.addEventListener('ionChange', function(ev) { el.addEventListener('ionChange', function(ev) {
updateRangeResult(ev.currentTarget); updateRangeResult(ev.target);
}); });
} }
progressValue.addEventListener('ionChange', function(ev) {
console.log(ev.detail.value);
brightnessValue.value = ev.detail.value;
contrastValue.value = ev.target.value;
});
function updateRangeResult(el) { function updateRangeResult(el) {
var resultEl = document.getElementById(`${el.id}Result`); var resultEl = document.getElementById(`${el.id}Result`);
resultEl.innerHTML = el.value; resultEl.innerHTML = Math.round(el.value);
} }
function increaseRangeValues() { function increaseRangeValues() {

View File

@ -12,6 +12,7 @@ export * from './components/loading/loading-interface';
export * from './components/popover/popover-interface'; export * from './components/popover/popover-interface';
export * from './components/nav/nav-interface'; export * from './components/nav/nav-interface';
export * from './components/router/utils/interface'; export * from './components/router/utils/interface';
export * from './components/range/range-interface';
export * from './components/select/select-interface'; export * from './components/select/select-interface';
export * from './components/select-popover/select-popover-interface'; export * from './components/select-popover/select-popover-interface';
export * from './components/toast/toast-interface'; export * from './components/toast/toast-interface';