From fc2ee6472f6f0944e80751d235ea69dbb6e741a1 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Fri, 4 Nov 2016 22:22:27 +0100 Subject: [PATCH] perf(picker): improves performance of picker and datepicker --- .scss-lint.yml | 4 + src/components/picker/picker-component.ts | 290 +++++++++++++--------- src/components/picker/picker.ios.scss | 2 + src/components/picker/picker.md.scss | 15 +- src/components/picker/picker.scss | 21 +- src/components/picker/picker.wp.scss | 15 +- src/config/mode-registry.ts | 3 + 7 files changed, 194 insertions(+), 156 deletions(-) diff --git a/.scss-lint.yml b/.scss-lint.yml index 80a15fc1fe..8f47412b54 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -203,3 +203,7 @@ linters: StringQuotes: enabled: true style: double_quotes + + PropertySpelling: + extra_properties: + - contain diff --git a/src/components/picker/picker-component.ts b/src/components/picker/picker-component.ts index baa9ea9604..4af2330752 100644 --- a/src/components/picker/picker-component.ts +++ b/src/components/picker/picker-component.ts @@ -1,7 +1,7 @@ -import { Component, ElementRef, EventEmitter, Input, HostListener, Output, QueryList, Renderer, ViewChild, ViewChildren, ViewEncapsulation } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, HostListener, NgZone, Output, QueryList, Renderer, ViewChild, ViewChildren, ViewEncapsulation } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { cancelRaf, pointerCoord, raf } from '../../util/dom'; +import { CSS, cancelRaf, pointerCoord, nativeRaf } from '../../util/dom'; import { clamp, isNumber, isPresent, isString } from '../../util/util'; import { Config } from '../../config/config'; import { Key } from '../../util/key'; @@ -11,7 +11,7 @@ import { PickerOptions, PickerColumn, PickerColumnOption } from './picker-option import { Haptic } from '../../util/haptic'; import { UIEventManager } from '../../util/ui-event-manager'; import { ViewController } from '../../navigation/view-controller'; - +import { Debouncer, NativeRafDebouncer } from '../../util/debouncer'; /** * @private @@ -21,14 +21,9 @@ import { ViewController } from '../../navigation/view-controller'; template: '
{{col.prefix}}
' + '
' + - '' + '
' + @@ -53,15 +48,24 @@ export class PickerColumnCmp { minY: number; maxY: number; rotateFactor: number; + scaleFactor: number; lastIndex: number; lastTempIndex: number; - receivingEvents: boolean = false; - events: UIEventManager = new UIEventManager(); + decelerateFunc: Function; + debouncer: Debouncer = new NativeRafDebouncer(); + events: UIEventManager = new UIEventManager(false); @Output() ionChange: EventEmitter = new EventEmitter(); - constructor(config: Config, private elementRef: ElementRef, private _sanitizer: DomSanitizer, private _haptic: Haptic) { + constructor( + config: Config, + private elementRef: ElementRef, + private _sanitizer: DomSanitizer, + private _zone: NgZone, + private _haptic: Haptic) { this.rotateFactor = config.getNumber('pickerRotateFactor', 0); + this.scaleFactor = config.getNumber('pickerScaleFactor', 1); + this.decelerateFunc = this.decelerate.bind(this); } ngAfterViewInit() { @@ -81,7 +85,8 @@ export class PickerColumnCmp { elementRef: this.elementRef, pointerDown: this.pointerStart.bind(this), pointerMove: this.pointerMove.bind(this), - pointerUp: this.pointerEnd.bind(this) + pointerUp: this.pointerEnd.bind(this), + capture: true }); } @@ -91,34 +96,36 @@ export class PickerColumnCmp { pointerStart(ev: UIEvent): boolean { console.debug('picker, pointerStart', ev.type, this.startY); - - // cancel any previous raf's that haven't fired yet - cancelRaf(this.rafId); - - // remember where the pointer started from` - this.startY = pointerCoord(ev).y; - - // reset everything - this.receivingEvents = true; - this.velocity = 0; - this.pos.length = 0; - this.pos.push(this.startY, Date.now()); - - let minY = (this.col.options.length - 1); - let maxY = 0; - - for (var i = 0; i < this.col.options.length; i++) { - if (!this.col.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); - this._haptic.gestureSelectionStart(); + this.debouncer.debounce(() => { + // cancel any previous raf's that haven't fired yet + if (this.rafId) { + cancelRaf(this.rafId); + this.rafId = null; + } + + // remember where the pointer started from` + this.startY = pointerCoord(ev).y; + + // reset everything + this.velocity = 0; + this.pos.length = 0; + this.pos.push(this.startY, Date.now()); + + let options = this.col.options; + let minY = (options.length - 1); + let maxY = 0; + for (var 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; } @@ -126,89 +133,86 @@ export class PickerColumnCmp { ev.preventDefault(); ev.stopPropagation(); - if (this.startY === null) { - return; - } + this.debouncer.debounce(() => { + if (this.startY === null) { + return; + } + let currentY = pointerCoord(ev).y; + this.pos.push(currentY, Date.now()); - var currentY = pointerCoord(ev).y; - this.pos.push(currentY, Date.now()); + // update the scroll position relative to pointer start position + let y = this.y + (currentY - this.startY); - // update the scroll position relative to pointer start position - var 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; - 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 if (y < this.maxY) { - // scrolling down below scroll area - y += Math.pow(this.maxY - y, 0.9); - this.bounceFrom = y; + } else { + this.bounceFrom = 0; + } - } else { - this.bounceFrom = 0; - } - - this.update(y, 0, false, false); - - let 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 - this._haptic.gestureSelectionChanged(); - } - this.lastTempIndex = currentIndex; + this.update(y, 0, false, false); + let 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 + this._haptic.gestureSelectionChanged(); + this.lastTempIndex = currentIndex; + } + }); } pointerEnd(ev: UIEvent) { - if (!this.receivingEvents) { + this.debouncer.cancel(); + + if (this.startY === null) { return; } - this.receivingEvents = false; + console.debug('picker, pointerEnd', ev.type); + 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; + } - } else if (this.startY !== null) { - var endY = pointerCoord(ev).y; + let endY = pointerCoord(ev).y; - console.debug('picker, pointerEnd', ev.type, endY); + this.pos.push(endY, Date.now()); - this.pos.push(endY, Date.now()); + let endPos = (this.pos.length - 1); + let startPos = endPos; + let timeRange = (Date.now() - 100); - var endPos = (this.pos.length - 1); - var startPos = endPos; - var timeRange = (Date.now() - 100); + // move pointer to position measured 100ms ago + for (var i = endPos; i > 0 && this.pos[i] > timeRange; i -= 2) { + startPos = i; + } - // move pointer to position measured 100ms ago - for (var i = endPos; i > 0 && this.pos[i] > timeRange; i -= 2) { - startPos = i; - } + if (startPos !== endPos) { + // compute relative movement between these two points + var timeOffset = (this.pos[endPos] - this.pos[startPos]); + var movedTop = (this.pos[startPos - 1] - this.pos[endPos - 1]); - if (startPos !== endPos) { - // compute relative movement between these two points - var timeOffset = (this.pos[endPos] - this.pos[startPos]); - var movedTop = (this.pos[startPos - 1] - this.pos[endPos - 1]); - - // based on XXms compute the movement to apply for each render step - this.velocity = ((movedTop / timeOffset) * FRAME_MS); - } - - if (Math.abs(endY - this.startY) > 3) { - ev.preventDefault(); - ev.stopPropagation(); - - var y = this.y + (endY - this.startY); - this.update(y, 0, true, true); - } + // based on XXms compute the movement to apply for each render step + this.velocity = ((movedTop / timeOffset) * FRAME_MS); + } + if (Math.abs(endY - this.startY) > 3) { + var y = this.y + (endY - this.startY); + this.update(y, 0, true, true); } this.startY = null; @@ -217,20 +221,20 @@ export class PickerColumnCmp { decelerate() { let y = 0; - cancelRaf(this.rafId); if (isNaN(this.y) || !this.optHeight) { // fallback in case numbers get outta wack this.update(y, 0, true, true); this._haptic.gestureSelectionEnd(); - } 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)); + this.velocity = (this.velocity > 0) + ? Math.max(this.velocity, 1) + : Math.min(this.velocity, -1); y = Math.round(this.y - this.velocity); @@ -252,7 +256,7 @@ export class PickerColumnCmp { if (notLockedIn) { // isn't locked in yet, keep decelerating until it is - this.rafId = raf(this.decelerate.bind(this)); + this.rafId = nativeRaf(this.decelerateFunc); } } else if (this.y % this.optHeight !== 0) { @@ -288,7 +292,10 @@ export class PickerColumnCmp { // if there isn't a selected index, then just use the top y position let y = (selectedIndex > -1) ? ((selectedIndex * this.optHeight) * -1) : 0; - cancelRaf(this.rafId); + if (this.rafId) { + cancelRaf(this.rafId); + this.rafId = null; + } this.velocity = 0; // so what y position we're at @@ -299,32 +306,69 @@ export class PickerColumnCmp { // ensure we've got a good round number :) y = Math.round(y); - this.col.selectedIndex = Math.max(Math.abs(Math.round(y / this.optHeight)), 0); + let i, button, opt, optOffset, visible, translateX, translateY, translateZ, rotateX, transform, selected; + const parent = this.colEle.nativeElement; + 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); - for (var i = 0; i < this.col.options.length; i++) { - var opt = this.col.options[i]; - var optTop = (i * this.optHeight); - var optOffset = (optTop + y); + const durationStr = (duration === 0) ? null : duration + 'ms'; + const scaleStr = `scale(${this.scaleFactor})`; - var rotateX = (optOffset * this.rotateFactor); - var translateX = 0; - var translateY = 0; - var translateZ = 0; + 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) { - translateX = 0; - translateZ = 90; - if (rotateX > 90 || rotateX < -90) { - translateX = -9999; - rotateX = 0; + rotateX = optOffset * this.rotateFactor; + if (Math.abs(rotateX) > 90) { + visible = false; + } else { + translateX = 0; + translateY = 0; + translateZ = 90; + transform = `rotateX(${rotateX}deg) `; } - } else { + translateX = 0; + translateZ = 0; translateY = optOffset; + if (Math.abs(translateY) > 170) { + visible = false; + } } - opt._trans = this._sanitizer.bypassSecurityTrustStyle(`rotateX(${rotateX}deg) translate3d(${translateX}px,${translateY}px,${translateZ}px)`); - opt._dur = (duration > 0 ? duration + 'ms' : ''); + 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._dur) { + opt._dur = duration; + button.style[CSS.transitionDuration] = durationStr; + } + // Update transform + if (transform !== opt._trans) { + opt._trans = transform; + button.style[CSS.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); + } + } } if (saveY) { @@ -340,7 +384,10 @@ export class PickerColumnCmp { // new selected index has changed from the last index // update the lastIndex and emit that it has changed this.lastIndex = this.col.selectedIndex; - this.ionChange.emit(this.col.options[this.col.selectedIndex]); + var ionChange = this.ionChange; + if (ionChange.observers.length > 0) { + this._zone.run(ionChange.emit.bind(ionChange, this.col.options[this.col.selectedIndex])); + } } } } @@ -567,5 +614,6 @@ export class PickerCmp { } let pickerIds = -1; +const PICKER_OPT_SELECTED = 'picker-opt-selected'; const DECELERATION_FRICTION = 0.97; const FRAME_MS = (1000 / 60); diff --git a/src/components/picker/picker.ios.scss b/src/components/picker/picker.ios.scss index dbd7e9c73d..a57a13a781 100644 --- a/src/components/picker/picker.ios.scss +++ b/src/components/picker/picker.ios.scss @@ -94,6 +94,8 @@ $picker-ios-option-offset-y: (($picker-ios-height - $picker-io margin: 0; padding: $picker-ios-option-padding; + height: 4.6rem; + font-size: $picker-ios-option-font-size; line-height: $picker-ios-option-height; diff --git a/src/components/picker/picker.md.scss b/src/components/picker/picker.md.scss index 424ab4bb21..6155bdf844 100644 --- a/src/components/picker/picker.md.scss +++ b/src/components/picker/picker.md.scss @@ -18,11 +18,10 @@ $picker-md-column-padding: 0 8px !default; $picker-md-option-padding: 0 !default; $picker-md-option-text-color: $list-md-text-color !default; -$picker-md-option-font-size: 18px !default; +$picker-md-option-font-size: 22px !default; $picker-md-option-height: 42px !default; $picker-md-option-offset-y: (($picker-md-height - $picker-md-toolbar-height) / 2) - ($picker-md-option-height / 2) - 10 !default; -$picker-md-option-selected-font-size: 22px !default; $picker-md-option-selected-color: color($colors-md, primary) !default; @@ -82,14 +81,13 @@ $picker-md-option-selected-color: color($colors-md, primary) !defaul pointer-events: none; } -.picker-md .picker-opts .button-effect { - display: none; -} .picker-md .picker-opt { margin: 0; padding: $picker-md-option-padding; + height: 4.3rem; + font-size: $picker-md-option-font-size; line-height: $picker-md-option-height; @@ -102,14 +100,9 @@ $picker-md-option-selected-color: color($colors-md, primary) !defaul pointer-events: auto; } -.picker-md .picker-opt .button-inner { - transition: 200ms; -} - .picker-md .picker-prefix, .picker-md .picker-suffix, -.picker-md .picker-opt-selected { - font-size: $picker-md-option-selected-font-size; +.picker-md .picker-opt.picker-opt-selected { color: $picker-md-option-selected-color; } diff --git a/src/components/picker/picker.scss b/src/components/picker/picker.scss index 2e843ea513..6115b3a1d7 100644 --- a/src/components/picker/picker.scss +++ b/src/components/picker/picker.scss @@ -88,41 +88,38 @@ ion-picker-cmp { white-space: nowrap; } +// contain property is supported by Chrome .picker-opt { position: absolute; top: 0; left: 0; + display: block; overflow: hidden; - flex: 1; - width: 100%; -} - -.picker-opt .button-inner { - display: block; - - overflow: hidden; + text-align: center; text-overflow: ellipsis; white-space: nowrap; + color: #000; - transition: opacity 150ms ease-in-out; + will-change: transform; + contain: strict; } .picker-opt.picker-opt-disabled { pointer-events: none; } -.picker-opt-disabled .button-inner { +.picker-opt-disabled { opacity: 0; } -.picker-opts-left .button-inner { +.picker-opts-left .picker-opt { justify-content: flex-start; } -.picker-opts-right .button-inner { +.picker-opts-right .picker-opt { justify-content: flex-end; } diff --git a/src/components/picker/picker.wp.scss b/src/components/picker/picker.wp.scss index 212e2b8b2a..d474990dfc 100644 --- a/src/components/picker/picker.wp.scss +++ b/src/components/picker/picker.wp.scss @@ -18,11 +18,10 @@ $picker-wp-column-padding: 0 4px !default; $picker-wp-option-padding: 0 !default; $picker-wp-option-text-color: $list-wp-text-color !default; -$picker-wp-option-font-size: 18px !default; +$picker-wp-option-font-size: 22px !default; $picker-wp-option-height: 42px !default; $picker-wp-option-offset-y: (($picker-wp-height - $picker-wp-toolbar-height) / 2) - ($picker-wp-option-height / 2) - 10 !default; -$picker-wp-option-selected-font-size: 22px !default; $picker-wp-option-selected-color: color($colors-wp, primary) !default; @@ -96,14 +95,12 @@ $picker-wp-option-selected-color: color($colors-wp, primary) !defaul pointer-events: none; } -.picker-wp .picker-opts .button-effect { - display: none; -} - .picker-wp .picker-opt { margin: 0; padding: $picker-wp-option-padding; + height: 4.2rem; + font-size: $picker-wp-option-font-size; line-height: $picker-wp-option-height; @@ -116,15 +113,9 @@ $picker-wp-option-selected-color: color($colors-wp, primary) !defaul pointer-events: auto; } -.picker-wp .picker-opt .button-inner { - transition: 200ms; -} - .picker-wp .picker-prefix, .picker-wp .picker-suffix, .picker-wp .picker-opt-selected { - font-size: $picker-wp-option-selected-font-size; - color: $picker-wp-option-selected-color; } diff --git a/src/config/mode-registry.ts b/src/config/mode-registry.ts index c20bf16149..2bce29f4f2 100644 --- a/src/config/mode-registry.ts +++ b/src/config/mode-registry.ts @@ -29,6 +29,7 @@ export const MODE_IOS: any = { pickerEnter: 'picker-slide-in', pickerLeave: 'picker-slide-out', pickerRotateFactor: -0.46, + pickerScaleFactor: 1, popoverEnter: 'popover-pop-in', popoverLeave: 'popover-pop-out', @@ -72,6 +73,7 @@ export const MODE_MD: any = { pickerEnter: 'picker-slide-in', pickerLeave: 'picker-slide-out', pickerRotateFactor: 0, + pickerScaleFactor: 0.81, popoverEnter: 'popover-md-pop-in', popoverLeave: 'popover-md-pop-out', @@ -115,6 +117,7 @@ export const MODE_WP: any = { pickerEnter: 'picker-slide-in', pickerLeave: 'picker-slide-out', pickerRotateFactor: 0, + pickerScaleFactor: 0.81, popoverEnter: 'popover-md-pop-in', popoverLeave: 'popover-md-pop-out',