import { Component, Element, Prop } from '@stencil/core'; import { clamp } from '../../utils/helpers'; import { GestureDetail, PickerColumn, PickerColumnOption, QueueController } from '../../index'; import { hapticSelectionChanged } from '../../utils'; @Component({ tag: 'ion-picker-column', host: { theme: 'picker-col' } }) export class PickerColumnCmp { private mode: string; private bounceFrom: number; private lastIndex: number; private lastTempIndex: number; private minY: number; private maxY: number; private optHeight: number; private pos: number[] = []; private rotateFactor: number; private scaleFactor: number; private startY: number|undefined; private velocity: number; private y = 0; @Element() private el: HTMLElement; @Prop({ context: 'queue' }) queue: QueueController; @Prop() col: PickerColumn; componentWillLoad() { let pickerRotateFactor = 0; let pickerScaleFactor = 0.81; if (this.mode === 'ios') { pickerRotateFactor = -0.46; pickerScaleFactor = 1; } this.rotateFactor = pickerRotateFactor; this.scaleFactor = pickerScaleFactor; } componentDidLoad() { // get the scrollable element within the column const colEl = this.el.querySelector('.picker-opts')!; // get the height of one option this.optHeight = (colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0); this.refresh(); } private optClick(ev: Event, index: number) { if (!this.velocity) { ev.preventDefault(); ev.stopPropagation(); this.setSelected(index, 150); } } private setSelected(selectedIndex: number, duration: number) { // if there is a selected index, then figure out it's y position // if there isn't a selected index, then just use the top y position const y = (selectedIndex > -1) ? ((selectedIndex * this.optHeight) * -1) : 0; this.velocity = 0; // set what y position we're at this.update(y, duration, true, true); } private update(y: number, duration: number, saveY: boolean, emitChange: boolean) { // ensure we've got a good round number :) y = Math.round(y); let i: number; let button: any; let opt: PickerColumnOption; let optOffset: number; let visible: boolean; let translateY = 0; let translateZ = 0; let rotateX: number; let transform: string; let selected: boolean; const parent = this.el.querySelector('.picker-opts')!; const children = parent.children; const length = children.length; const selectedIndex = this.col.selectedIndex = Math.min(Math.max(Math.round(-y / this.optHeight), 0), length - 1); const durationStr = (duration === 0) ? null : duration + 'ms'; const scaleStr = `scale(${this.scaleFactor})`; for (i = 0; i < length; i++) { button = children[i]; opt = this.col.options[i]; optOffset = (i * this.optHeight) + y; visible = true; transform = ''; if (this.rotateFactor !== 0) { rotateX = optOffset * this.rotateFactor; if (Math.abs(rotateX) > 90) { visible = false; } else { translateY = 0; translateZ = 90; transform = `rotateX(${rotateX}deg) `; } } else { translateZ = 0; translateY = optOffset; if (Math.abs(translateY) > 170) { visible = false; } } selected = selectedIndex === i; if (visible) { transform += `translate3d(0px,${translateY}px,${translateZ}px) `; if (this.scaleFactor !== 1 && !selected) { transform += scaleStr; } } else { transform = 'translate3d(-9999px,0px,0px)'; } // Update transition duration if (duration !== opt.duration) { opt.duration = duration; button.style.transitionDuration = durationStr; } // Update transform if (transform !== opt.transform) { opt.transform = transform; button.style.transform = transform; } // Update selected item if (selected !== opt.selected) { opt.selected = selected; if (selected) { button.classList.add(PICKER_OPT_SELECTED); } else { button.classList.remove(PICKER_OPT_SELECTED); } } } this.col.prevSelected = selectedIndex; if (saveY) { this.y = y; } if (emitChange) { if (this.lastIndex === undefined) { // have not set a last index yet this.lastIndex = this.col.selectedIndex; } else if (this.lastIndex !== this.col.selectedIndex) { // new selected index has changed from the last index // update the lastIndex and emit that it has changed this.lastIndex = this.col.selectedIndex; // TODO ionChange event // var ionChange = this.ionChange; // if (ionChange.observers.length > 0) { // this._zone.run(ionChange.emit.bind(ionChange, this.col.options[this.col.selectedIndex])); // } } } } private decelerate() { let y = 0; if (isNaN(this.y) || !this.optHeight) { // fallback in case numbers get outta wack this.update(y, 0, true, true); } else if (Math.abs(this.velocity) > 0) { // still decelerating this.velocity *= DECELERATION_FRICTION; // do not let it go slower than a velocity of 1 this.velocity = (this.velocity > 0) ? Math.max(this.velocity, 1) : Math.min(this.velocity, -1); y = Math.round(this.y - this.velocity); if (y > this.minY) { // whoops, it's trying to scroll up farther than the options we have! y = this.minY; this.velocity = 0; } else if (y < this.maxY) { // gahh, it's trying to scroll down farther than we can! y = this.maxY; this.velocity = 0; } const notLockedIn = (y % this.optHeight !== 0 || Math.abs(this.velocity) > 1); this.update(y, 0, true, !notLockedIn); if (notLockedIn) { // isn't locked in yet, keep decelerating until it is this.queue.read(() => this.decelerate()); } } else if (this.y % this.optHeight !== 0) { // needs to still get locked into a position so options line up const currentPos = Math.abs(this.y % this.optHeight); // create a velocity in the direction it needs to scroll this.velocity = (currentPos > (this.optHeight / 2) ? 1 : -1); this.decelerate(); } const currentIndex = Math.max(Math.abs(Math.round(y / this.optHeight)), 0); if (currentIndex !== this.lastTempIndex) { // Trigger a haptic event for physical feedback that the index has changed hapticSelectionChanged(); } this.lastTempIndex = currentIndex; } // TODO should this check disabled? private canStart() { return true; } private onDragStart(detail: GestureDetail): boolean { console.debug('picker, onDragStart', detail, this.startY); // We have to prevent default in order to block scrolling under the picker // but we DO NOT have to stop propagation, since we still want // some "click" events to capture if (detail.event) { detail.event.preventDefault(); detail.event.stopPropagation(); } // remember where the pointer started from this.startY = detail.startY; // reset everything this.velocity = 0; this.pos.length = 0; this.pos.push(this.startY, Date.now()); const options = this.col.options; let minY = (options.length - 1); let maxY = 0; for (let i = 0; i < options.length; i++) { if (!options[i].disabled) { minY = Math.min(minY, i); maxY = Math.max(maxY, i); } } this.minY = (minY * this.optHeight * -1); this.maxY = (maxY * this.optHeight * -1); return true; } private onDragMove(detail: GestureDetail) { if (detail.event) { detail.event.preventDefault(); detail.event.stopPropagation(); } const currentY = detail.currentY; this.pos.push(currentY, Date.now()); if (this.startY === undefined) { return; } // update the scroll position relative to pointer start position let y = this.y + (currentY - this.startY); if (y > this.minY) { // scrolling up higher than scroll area y = Math.pow(y, 0.8); this.bounceFrom = y; } else if (y < this.maxY) { // scrolling down below scroll area y += Math.pow(this.maxY - y, 0.9); this.bounceFrom = y; } else { this.bounceFrom = 0; } this.update(y, 0, false, false); const currentIndex = Math.max(Math.abs(Math.round(y / this.optHeight)), 0); if (currentIndex !== this.lastTempIndex) { this.lastTempIndex = currentIndex; } } private onDragEnd(detail: GestureDetail) { if (this.startY === undefined) { return; } console.debug('picker, onDragEnd', detail); this.velocity = 0; if (this.bounceFrom > 0) { // bounce back up this.update(this.minY, 100, true, true); return; } else if (this.bounceFrom < 0) { // bounce back down this.update(this.maxY, 100, true, true); return; } const endY = detail.currentY; this.pos.push(endY, Date.now()); const endPos = (this.pos.length - 1); let startPos = endPos; const timeRange = (Date.now() - 100); // move pointer to position measured 100ms ago for (let i = endPos; i > 0 && this.pos[i] > timeRange; i -= 2) { startPos = i; } if (startPos !== endPos) { // compute relative movement between these two points const timeOffset = (this.pos[endPos] - this.pos[startPos]); const movedTop = (this.pos[startPos - 1] - this.pos[endPos - 1]); // based on XXms compute the movement to apply for each render step const velocity = ((movedTop / timeOffset) * FRAME_MS); this.velocity = clamp(-MAX_PICKER_SPEED, velocity, MAX_PICKER_SPEED); } if (Math.abs(endY - this.startY) > 3) { const y = this.y + (endY - this.startY); this.update(y, 0, true, true); } this.startY = undefined; this.decelerate(); } private refresh() { let min = this.col.options.length - 1; let max = 0; const options = this.col.options; for (let i = 0; i < options.length; i++) { if (!options[i].disabled) { min = Math.min(min, i); max = Math.max(max, i); } } const selectedIndex = clamp(min, this.col.selectedIndex!, max); if (this.col.prevSelected !== selectedIndex) { const y = (selectedIndex * this.optHeight) * -1; this.velocity = 0; this.update(y, 150, true, false); } } hostData() { return { class: { 'picker-opts-left': this.col.align === 'left', 'picker-opts-right': this.col.align === 'right' }, style: { 'max-width': this.col.columnWidth } }; } render() { const col = this.col; const options = col.options.map(o => { if (typeof o === 'string') { o = { text: o }; } return o; }) .filter(clientInformation => clientInformation !== null); const results: JSX.Element[] = []; if (col.prefix) { results.push(