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,
GestureDetail,
InputChangeEvent,
Knob,
LoadingOptions,
Menu,
MenuChangeEventDetail,
@ -4494,14 +4495,14 @@ declare global {
namespace StencilComponents {
interface IonRangeKnob {
'disabled': boolean;
'knob': string;
'knob': Knob;
'labelId': string;
'max': number;
'min': number;
'pin': boolean;
'pressed': boolean;
'ratio': number;
'val': number;
'value': number;
}
}
@ -4525,7 +4526,7 @@ declare global {
namespace JSXElements {
export interface IonRangeKnobAttributes extends HTMLAttributes {
'disabled'?: boolean;
'knob'?: string;
'knob'?: Knob;
'labelId'?: string;
'max'?: number;
'min'?: number;
@ -4534,7 +4535,7 @@ declare global {
'pin'?: boolean;
'pressed'?: boolean;
'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`.
*/
'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`.
*/

View File

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

View File

@ -14,7 +14,7 @@ boolean
#### knob
string
number
#### labelId
@ -47,7 +47,7 @@ boolean
number
#### val
#### value
number
@ -61,7 +61,7 @@ boolean
#### knob
string
number
#### label-id
@ -94,7 +94,7 @@ boolean
number
#### val
#### value
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 { clamp, debounceEvent, deferEvent } from '../../utils/helpers';
export interface Tick {
ratio: number | (() => number);
left: string;
active?: boolean;
}
import { Knob, RangeEventDetail, RangeValue } from './range-interface';
@Component({
tag: 'ion-range',
@ -20,24 +15,15 @@ export interface Tick {
})
export class Range implements BaseInput {
hasFocus = false;
private noUpdate = false;
private rect!: ClientRect;
private hasFocus = false;
@Element() el!: HTMLElement;
@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;
@Element() el!: HTMLStencilElement;
@State() private ratioA = 0;
@State() private ratioB = 0;
@State() private pressedKnob = Knob.None;
/**
* The color to use from your Sass `$colors` map.
@ -74,16 +60,16 @@ export class Range implements BaseInput {
*/
@Prop() dualKnobs = false;
/**
* Maximum integer value of the range. Defaults to `100`.
*/
@Prop() max = 100;
/**
* Minimum integer value of the range. Defaults to `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
* is pressed. Defaults to `false`.
@ -113,11 +99,12 @@ export class Range implements BaseInput {
/**
* the value of the range.
*/
@Prop({ mutable: true }) value: any;
@Prop({ mutable: true }) value: any = 0;
@Watch('value')
protected valueChanged(value: any) {
this.inputUpdated();
this.emitStyle();
protected valueChanged(value: RangeValue) {
if (!this.noUpdate) {
this.updateRatio();
}
this.ionChange.emit({value});
}
@ -146,12 +133,45 @@ export class Range implements BaseInput {
componentWillLoad() {
this.ionStyle = deferEvent(this.ionStyle);
this.inputUpdated();
this.createTicks();
this.updateRatio();
this.debounceChanged();
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() {
this.ionStyle.emit({
'range-disabled': this.disabled
@ -174,244 +194,130 @@ export class Range implements BaseInput {
}
}
private inputUpdated() {
const val = this.value;
if (this.dualKnobs) {
this.valA = val.lower;
this.valB = val.upper;
this.ratioA = this.valueToRatio(val.lower);
this.ratioB = this.valueToRatio(val.upper);
} else {
this.valA = val;
this.ratioA = this.valueToRatio(val);
}
this.updateBar();
private onDragStart(detail: GestureDetail) {
this.fireFocus();
const el = this.el.querySelector('.range-slider')!;
const rect = this.rect = el.getBoundingClientRect() as any;
const currentX = detail.currentX;
// figure out which knob they started closer to
const ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
this.pressedKnob = (!this.dualKnobs || (Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio)))
? Knob.A
: Knob.B;
// update the active knob's position
this.update(currentX);
}
private updateBar() {
const ratioA = this.ratioA;
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}%`;
private onDragMove(detail: GestureDetail) {
this.update(detail.currentX);
}
this.updateTicks();
private onDragEnd(detail: GestureDetail) {
this.update(detail.currentX);
this.pressedKnob = Knob.None;
this.fireBlur();
}
private createTicks() {
if (this.snaps) {
for (let value = this.min; value <= this.max; value += this.step) {
const ratio = this.valueToRatio(value);
this.ticks.push({
ratio,
left: `${ratio * 100}%`
});
}
this.updateTicks();
}
}
private updateTicks() {
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) {
private update(currentX: number) {
// figure out where the pointer is currently at
// update the knob being interacted with
let ratio = clamp(0, (current.x - rect.left) / rect.width, 1);
const val = this.ratioToValue(ratio);
const rect = this.rect;
let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
if (this.snaps) {
// 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
this.pressed = isPressed;
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;
if (this.pressedKnob === Knob.A) {
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 {
// single knob only has one value
value = this.valA;
this.ratioB = ratio;
}
// Update input value
this.value = value;
return true;
this.updateValue();
}
/**
* 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.
*/
@Method()
ratio(): number {
private get valA() {
return ratioToValue(this.ratioA, this.min, this.max, this.step);
}
private get valB() {
return ratioToValue(this.ratioB, this.min, this.max, this.step);
}
private get ratioLower() {
if (this.dualKnobs) {
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;
}
/**
* 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`.
*/
@Method()
ratioUpper() {
private updateRatio() {
const value = this.getValue() as any;
const {min, max} = this;
if (this.dualKnobs) {
return Math.max(this.ratioA, this.ratioB);
}
return null;
}
@Listen('ionIncrease, ionDecrease')
keyChng(ev: RangeEvent) {
const step = this.step;
if (ev.detail.knob === 'knobB') {
if (ev.detail.isIncrease) {
this.valB += step;
this.ratioA = valueToRatio(value.lower, min, max);
this.ratioB = valueToRatio(value.upper, min, max);
} else {
this.valB -= step;
this.ratioA = valueToRatio(value, min, max);
}
this.valB = clamp(this.min, this.valB, this.max);
this.ratioB = this.valueToRatio(this.valB);
} else {
if (ev.detail.isIncrease) {
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) {
if (this.disabled) return false;
this.fireFocus();
private updateValue() {
this.noUpdate = true;
const current = { x: detail.currentX, y: detail.currentY };
const el = this.el.querySelector('.range-slider')!;
const rect = el.getBoundingClientRect();
this.rect = rect;
const {valA, valB} = this;
this.value = (!this.dualKnobs)
? valA
: {
lower: Math.min(valA, valB),
upper: Math.max(valA, valB)
};
// figure out which knob they started closer to
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);
this.noUpdate = false;
}
hostData() {
return {
class: {
'range-disabled': this.disabled,
'range-pressed': this.pressed,
'range-pressed': this.pressedKnob !== Knob.None,
'range-has-pin': this.pin
}
};
}
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 [
<slot name="start"></slot>,
<ion-gesture
@ -426,12 +332,14 @@ export class Range implements BaseInput {
threshold={0}>
<div class="range-slider">
{this.ticks.map(t =>
{ticks.map(t =>
<div
style={{ left: t.left! }}
style={{ left: t.left }}
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" />
@ -439,33 +347,28 @@ export class Range implements BaseInput {
class="range-bar range-bar-active"
role="presentation"
style={{
left: this.barL,
right: this.barR
left: barL,
right: barR
}}
/>
<ion-range-knob
class="range-knob-handle"
knob="knobA"
pressed={this.pressedA}
knob={Knob.A}
pressed={this.pressedKnob === Knob.A}
value={this.valA}
ratio={this.ratioA}
val={this.valA}
pin={this.pin}
min={this.min}
max={this.max}
/>
min={min}
max={max}/>
{this.dualKnobs
? <ion-range-knob
class="range-knob-handle"
knob="knobB"
pressed={this.pressedB}
{ this.dualKnobs &&
<ion-range-knob
knob={Knob.B}
pressed={this.pressedKnob === Knob.B}
value={this.valB}
ratio={this.ratioB}
val={this.valB}
pin={this.pin}
min={this.min}
max={this.max}
/>
: null}
min={min}
max={max} /> }
</div>
</ion-gesture>,
<slot name="end"></slot>
@ -473,9 +376,15 @@ export class Range implements BaseInput {
}
}
export interface RangeEvent extends Event {
detail: {
isIncrease: boolean,
knob: string
};
export function ratioToValue(ratio: number, min: number, max: number, step: number): number {
let value = ((max - min) * ratio);
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.
## 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-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-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-range pin="true" color="secondary" value="86">
@ -48,7 +48,7 @@
Dynamic Value
</ion-list-header>
<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>
</ion-item>
<ion-item>
@ -103,21 +103,32 @@
</ion-app>
<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++) {
var el = document.getElementById(ranges[i]);
var el = ranges[i];
el.value = (i + 1) * 10;
updateRangeResult(el);
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) {
var resultEl = document.getElementById(`${el.id}Result`);
resultEl.innerHTML = el.value;
resultEl.innerHTML = Math.round(el.value);
}
function increaseRangeValues() {

View File

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