From 5c38921595ce1c7fd101e5fb59d3f661d6eca4d3 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Mon, 20 Jun 2016 20:19:54 +0200 Subject: [PATCH] feat(list): reorder list items References #5595 --- src/components/item/item-reorder-gesture.ts | 148 +++++++++++++++++++ src/components/item/item-reorder.scss | 48 +++++++ src/components/item/item-reorder.ts | 17 +++ src/components/item/item-sliding-gesture.ts | 4 +- src/components/item/item.scss | 1 + src/components/item/item.ts | 18 ++- src/components/item/test/reorder/e2e.ts | 1 + src/components/item/test/reorder/index.ts | 30 ++++ src/components/item/test/reorder/main.html | 22 +++ src/components/list/list.ts | 129 ++++++++++++++--- src/util/ui-event-manager.ts | 150 ++++++++++++++++++++ 11 files changed, 545 insertions(+), 23 deletions(-) create mode 100644 src/components/item/item-reorder-gesture.ts create mode 100644 src/components/item/item-reorder.scss create mode 100644 src/components/item/item-reorder.ts create mode 100644 src/components/item/test/reorder/e2e.ts create mode 100644 src/components/item/test/reorder/index.ts create mode 100644 src/components/item/test/reorder/main.html create mode 100644 src/util/ui-event-manager.ts diff --git a/src/components/item/item-reorder-gesture.ts b/src/components/item/item-reorder-gesture.ts new file mode 100644 index 00000000000..13d534ea12e --- /dev/null +++ b/src/components/item/item-reorder-gesture.ts @@ -0,0 +1,148 @@ +import {Item} from './item'; +import {List} from '../list/list'; +import {UIEventManager} from '../../util/ui-event-manager'; +import {closest, Coordinates, pointerCoord, CSS, nativeRaf} from '../../util/dom'; + + +const AUTO_SCROLL_MARGIN = 60; +const SCROLL_JUMP = 10; +const ITEM_REORDER_ACTIVE = 'reorder-active'; + +/** + * @private + */ +export class ItemReorderGesture { + private selectedItem: Item = null; + private offset: Coordinates; + private lastToIndex: number; + private lastYcoord: number; + private emptyZone: boolean; + + private itemHeight: number; + private windowHeight: number; + + private events: UIEventManager = new UIEventManager(false); + + constructor(public list: List) { + let element = this.list.getNativeElement(); + this.events.pointerEvents(element, + (ev: any) => this.onDragStart(ev), + (ev: any) => this.onDragMove(ev), + (ev: any) => this.onDragEnd(ev)); + } + + private onDragStart(ev: any): boolean { + let itemEle = ev.target; + if (itemEle.nodeName !== 'ION-REORDER') { + return false; + } + + let item = itemEle['$ionComponent']; + if (!item) { + console.error('item does not contain ion component'); + return false; + } + ev.preventDefault(); + + // Preparing state + this.offset = pointerCoord(ev); + this.offset.y += this.list.scrollContent(0); + this.selectedItem = item; + this.itemHeight = item.height(); + this.lastToIndex = item.index; + this.windowHeight = window.innerHeight - AUTO_SCROLL_MARGIN; + item.setCssClass(ITEM_REORDER_ACTIVE, true); + return true; + } + + private onDragMove(ev: any) { + if (!this.selectedItem) { + return; + } + ev.preventDefault(); + + // Get coordinate + var coord = pointerCoord(ev); + + // Scroll if we reach the scroll margins + let scrollPosition = this.scroll(coord); + + // Update selected item position + let ydiff = Math.round(coord.y - this.offset.y + scrollPosition); + this.selectedItem.setCssStyle(CSS.transform, `translateY(${ydiff}px)`); + + // Only perform hit test if we moved at least 30px from previous position + if (Math.abs(coord.y - this.lastYcoord) < 30) { + return; + } + + // Hit test + let overItem = this.itemForCoord(coord); + if (!overItem) { + this.emptyZone = true; + return; + } + + // Move surrounding items if needed + let toIndex = overItem.index; + if (toIndex !== this.lastToIndex || this.emptyZone) { + let fromIndex = this.selectedItem.index; + this.lastToIndex = overItem.index; + this.lastYcoord = coord.y; + this.emptyZone = false; + nativeRaf(() => { + this.list.reorderMove(fromIndex, toIndex, this.itemHeight); + }); + } + } + + private onDragEnd(ev: any) { + if (!this.selectedItem) { + return; + } + + nativeRaf(() => { + let toIndex = this.lastToIndex; + let fromIndex = this.selectedItem.index; + this.selectedItem.setCssClass(ITEM_REORDER_ACTIVE, false); + this.selectedItem = null; + this.list.reorderEmit(fromIndex, toIndex); + }); + } + + private itemForCoord(coord: Coordinates): Item { + let element = document.elementFromPoint(this.offset.x - 100, coord.y); + if (!element) { + return null; + } + element = closest(element, 'ion-item', true); + if (!element) { + return null; + } + let item = (element)['$ionComponent']; + if (!item) { + console.error('item does not have $ionComponent'); + return null; + } + return item; + } + + private scroll(coord: Coordinates): number { + let scrollDiff = 0; + if (coord.y < AUTO_SCROLL_MARGIN) { + scrollDiff = -SCROLL_JUMP; + } else if (coord.y > this.windowHeight) { + scrollDiff = SCROLL_JUMP; + } + return this.list.scrollContent(scrollDiff); + } + + /** + * @private + */ + destroy() { + this.events.unlistenAll(); + this.events = null; + this.list = null; + } +} diff --git a/src/components/item/item-reorder.scss b/src/components/item/item-reorder.scss new file mode 100644 index 00000000000..ebf1f5dd904 --- /dev/null +++ b/src/components/item/item-reorder.scss @@ -0,0 +1,48 @@ + +// Item reorder +// -------------------------------------------------- + +ion-reorder { + display: none; + + flex: 1; + align-items: center; + justify-content: center; + + max-width: 40px; + height: 100%; + + font-size: 1.6em; + + pointer-events: all; + touch-action: manipulation; + + ion-icon { + pointer-events: none; + } +} + +.reorder-enabled { + + ion-item { + will-change: transform; + } + + ion-reorder { + display: flex; + } +} + +ion-item.reorder-active { + z-index: 4; + + box-shadow: 0 0 10px rgba(0, 0, 0, .5); + opacity: .8; + transition: none; + + pointer-events: none; + + ion-reorder { + pointer-events: none; + } +} diff --git a/src/components/item/item-reorder.ts b/src/components/item/item-reorder.ts new file mode 100644 index 00000000000..36268b8f4c7 --- /dev/null +++ b/src/components/item/item-reorder.ts @@ -0,0 +1,17 @@ +import {Component, ElementRef, Inject, forwardRef} from '@angular/core'; +import {Item} from './item'; + +/** + * @private + */ +@Component({ + selector: 'ion-reorder', + template: `` +}) +export class ItemReorder { + constructor( + @Inject(forwardRef(() => Item)) item: Item, + elementRef: ElementRef) { + elementRef.nativeElement['$ionComponent'] = item; + } +} diff --git a/src/components/item/item-sliding-gesture.ts b/src/components/item/item-sliding-gesture.ts index 025ec1ac5ab..3495ecbcc58 100644 --- a/src/components/item/item-sliding-gesture.ts +++ b/src/components/item/item-sliding-gesture.ts @@ -12,8 +12,8 @@ export class ItemSlidingGesture extends DragGesture { selectedContainer: ItemSliding = null; openContainer: ItemSliding = null; - constructor(public list: List, public listEle: HTMLElement) { - super(listEle, { + constructor(public list: List) { + super(list.getNativeElement(), { direction: 'x', threshold: DRAG_THRESHOLD }); diff --git a/src/components/item/item.scss b/src/components/item/item.scss index 7c043ce39e5..3954a82872d 100644 --- a/src/components/item/item.scss +++ b/src/components/item/item.scss @@ -85,3 +85,4 @@ ion-input.item { @import "item-media"; @import "item-sliding"; +@import "item-reorder"; diff --git a/src/components/item/item.ts b/src/components/item/item.ts index e56416d1940..e64d79f812a 100644 --- a/src/components/item/item.ts +++ b/src/components/item/item.ts @@ -1,9 +1,10 @@ -import {Component, ContentChildren, forwardRef, ViewChild, ContentChild, Renderer, ElementRef, ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core'; +import {Component, ContentChildren, forwardRef, Input, ViewChild, ContentChild, Renderer, ElementRef, ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core'; import {Button} from '../button/button'; import {Form} from '../../util/form'; import {Icon} from '../icon/icon'; import {Label} from '../label/label'; +import {ItemReorder} from './item-reorder'; /** @@ -235,11 +236,13 @@ import {Label} from '../label/label'; '' + '' + '' + + '' + '' + '', host: { 'class': 'item' }, + directives: [forwardRef(() => ItemReorder)], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) @@ -249,6 +252,11 @@ export class Item { private _label: Label; private _viewLabel: boolean = true; + /** + * @private + */ + @Input() index: number; + /** * @private */ @@ -261,6 +269,7 @@ export class Item { constructor(form: Form, private _renderer: Renderer, private _elementRef: ElementRef) { this.id = form.nextId().toString(); + _elementRef.nativeElement['$ionComponent'] = this; } /** @@ -354,4 +363,11 @@ export class Item { icon.addClass('item-icon'); }); } + + /** + * @private + */ + height(): number { + return this._elementRef.nativeElement.offsetHeight; + } } diff --git a/src/components/item/test/reorder/e2e.ts b/src/components/item/test/reorder/e2e.ts new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/components/item/test/reorder/e2e.ts @@ -0,0 +1 @@ + diff --git a/src/components/item/test/reorder/index.ts b/src/components/item/test/reorder/index.ts new file mode 100644 index 00000000000..cf5c190cb7e --- /dev/null +++ b/src/components/item/test/reorder/index.ts @@ -0,0 +1,30 @@ +import {Component, ChangeDetectorRef} from '@angular/core'; +import {ionicBootstrap} from '../../../../../src'; + + +@Component({ + templateUrl: 'main.html' +}) +class E2EPage { + items: any[] = []; + isReordering: boolean = false; + + constructor(private d: ChangeDetectorRef) { + let nu = 30; + for (let i = 0; i < nu; i++) { + this.items.push(i); + } + } + + toggle() { + this.isReordering = !this.isReordering; + } + + reorder(indexes: any) { + let element = this.items[indexes.from]; + this.items.splice(indexes.from, 1); + this.items.splice(indexes.to, 0, element); + } +} + +ionicBootstrap(E2EPage); diff --git a/src/components/item/test/reorder/main.html b/src/components/item/test/reorder/main.html new file mode 100644 index 00000000000..b8f19189630 --- /dev/null +++ b/src/components/item/test/reorder/main.html @@ -0,0 +1,22 @@ + + Reorder items + + + + + + + + + + {{item}} + + + + + diff --git a/src/components/list/list.ts b/src/components/list/list.ts index 2abc477af6a..58e1a415171 100644 --- a/src/components/list/list.ts +++ b/src/components/list/list.ts @@ -1,7 +1,11 @@ -import {Directive, ElementRef, Renderer, Attribute, NgZone} from '@angular/core'; +import {Directive, ElementRef, EventEmitter, Renderer, Input, Optional, Output, Attribute, NgZone} from '@angular/core'; +import {Content} from '../content/content'; import {Ion} from '../ion'; import {ItemSlidingGesture} from '../item/item-sliding-gesture'; +import {ItemReorderGesture} from '../item/item-reorder-gesture'; +import {isTrueProperty} from '../../util/util'; +import {nativeTimeout} from '../../util/dom'; /** * The List is a widely used interface element in almost any mobile app, @@ -20,32 +24,30 @@ import {ItemSlidingGesture} from '../item/item-sliding-gesture'; * */ @Directive({ - selector: 'ion-list' + selector: 'ion-list', + host: { + '[class.reorder-enabled]': '_enableReorder', + } }) export class List extends Ion { + private _enableReorder: boolean = false; private _enableSliding: boolean = false; + private _slidingGesture: ItemSlidingGesture; + private _reorderGesture: ItemReorderGesture; + private _lastToIndex: number = -1; - /** - * @private - */ - ele: HTMLElement; + @Output() ionItemReorder: EventEmitter<{ from: number, to: number }> = new EventEmitter(); - /** - * @private - */ - slidingGesture: ItemSlidingGesture; - - constructor(elementRef: ElementRef, private _zone: NgZone) { + constructor(elementRef: ElementRef, private _zone: NgZone, @Optional() private _content: Content) { super(elementRef); - this.ele = elementRef.nativeElement; } /** * @private */ ngOnDestroy() { - this.slidingGesture && this.slidingGesture.destroy(); - this.ele = this.slidingGesture = null; + this._slidingGesture && this._slidingGesture.destroy(); + this._reorderGesture && this._reorderGesture.destroy(); } /** @@ -76,12 +78,10 @@ export class List extends Ion { this._enableSliding = shouldEnable; if (shouldEnable) { console.debug('enableSlidingItems'); - this._zone.runOutsideAngular(() => { - setTimeout(() => this.slidingGesture = new ItemSlidingGesture(this, this.ele)); - }); + nativeTimeout(() => this._slidingGesture = new ItemSlidingGesture(this)); } else { - this.slidingGesture && this.slidingGesture.unlisten(); + this._slidingGesture && this._slidingGesture.unlisten(); } } @@ -105,7 +105,96 @@ export class List extends Ion { * ``` */ closeSlidingItems() { - this.slidingGesture && this.slidingGesture.closeOpened(); + this._slidingGesture && this._slidingGesture.closeOpened(); + } + + /** + * @private + */ + reorderEmit(fromIndex: number, toIndex: number) { + this.reorderReset(); + if (fromIndex !== toIndex) { + this._zone.run(() => { + this.ionItemReorder.emit({ + from: fromIndex, + to: toIndex, + }); + }); + } + } + + /** + * @private + */ + scrollContent(scroll: number) { + let scrollTop = this._content.getScrollTop() + scroll; + if (scroll !== 0) { + this._content.scrollTo(0, scrollTop, 0); + } + return scrollTop; + } + + /** + * @private + */ + reorderReset() { + let children = this.elementRef.nativeElement.children; + let len = children.length; + for (let i = 0; i < len; i++) { + children[i].style.transform = ''; + } + this._lastToIndex = -1; + } + + /** + * @private + */ + reorderMove(fromIndex: number, toIndex: number, itemHeight: number) { + if (this._lastToIndex === -1) { + this._lastToIndex = fromIndex; + } + let lastToIndex = this._lastToIndex; + this._lastToIndex = toIndex; + + let children = this.elementRef.nativeElement.children; + if (toIndex >= lastToIndex) { + for (var i = lastToIndex; i <= toIndex; i++) { + if (i !== fromIndex) { + children[i].style.transform = (i > fromIndex) + ? `translateY(${-itemHeight}px)` : ''; + } + } + } + + if (toIndex <= lastToIndex) { + for (var i = toIndex; i <= lastToIndex; i++) { + if (i !== fromIndex) { + children[i].style.transform = (i < fromIndex) + ? `translateY(${itemHeight}px)` : ''; + } + } + } + } + + @Input() + get reorder(): boolean { + return this._enableReorder; + } + + set reorder(val: boolean) { + let enabled = isTrueProperty(val); + if (this._enableReorder === enabled) { + return; + } + + this._enableReorder = enabled; + if (enabled) { + console.debug('enableReorderItems'); + nativeTimeout(() => this._reorderGesture = new ItemReorderGesture(this)); + + } else { + this._reorderGesture && this._reorderGesture.destroy(); + } } } diff --git a/src/util/ui-event-manager.ts b/src/util/ui-event-manager.ts new file mode 100644 index 00000000000..f85bab68199 --- /dev/null +++ b/src/util/ui-event-manager.ts @@ -0,0 +1,150 @@ +import {ElementRef} from '@angular/core'; + +const MOUSE_WAIT = 2 * 1000; + + +class PointerEvents { + private rmTouchStart: Function = null; + private rmTouchMove: Function = null; + private rmTouchEnd: Function = null; + + private rmMouseStart: Function = null; + private rmMouseMove: Function = null; + private rmMouseUp: Function = null; + + private lastTouchEvent: number = 0; + + constructor(private ele: any, + private pointerDown: any, + private pointerMove: any, + private pointerUp: any, + private zone: boolean, + private option: any) { + + this.rmTouchStart = listenEvent(ele, 'touchstart', zone, option, (ev: any) => this.handleTouchStart(ev)); + this.rmMouseStart = listenEvent(ele, 'mousedown', zone, option, (ev: any) => this.handleMouseDown(ev)); + } + + private handleTouchStart(ev: any) { + this.lastTouchEvent = Date.now() + MOUSE_WAIT; + if (!this.pointerDown(ev)) { + return; + } + if (!this.rmTouchMove) { + this.rmTouchMove = listenEvent(this.ele, 'touchmove', this.zone, this.option, this.pointerMove); + } + if (!this.rmTouchEnd) { + this.rmTouchEnd = listenEvent(this.ele, 'touchend', this.zone, this.option, (ev: any) => this.handleTouchEnd(ev)); + } + } + + private handleMouseDown(ev: any) { + if (this.lastTouchEvent > Date.now()) { + console.debug('mousedown event dropped because of previous touch'); + return; + } + if (!this.pointerDown(ev)) { + return; + } + if (!this.rmMouseMove) { + this.rmMouseMove = listenEvent(window, 'mousemove', this.zone, this.option, this.pointerMove); + } + if (!this.rmMouseUp) { + this.rmMouseUp = listenEvent(window, 'mouseup', this.zone, this.option, (ev: any) => this.handleMouseUp(ev)); + } + } + + private handleTouchEnd(ev: any) { + this.rmTouchMove && this.rmTouchMove(); + this.rmTouchMove = null; + this.rmTouchEnd && this.rmTouchEnd(); + this.rmTouchEnd = null; + + this.pointerUp(ev); + } + + private handleMouseUp(ev: any) { + this.rmMouseMove && this.rmMouseMove(); + this.rmMouseMove = null; + this.rmMouseUp && this.rmMouseUp(); + this.rmMouseUp = null; + + this.pointerUp(ev); + } + + destroy() { + this.rmTouchStart && this.rmTouchStart(); + this.rmTouchMove && this.rmTouchMove(); + this.rmTouchEnd && this.rmTouchEnd(); + + this.rmMouseStart && this.rmMouseStart(); + this.rmMouseMove && this.rmMouseMove(); + this.rmMouseUp && this.rmMouseUp(); + + this.rmTouchStart = null; + this.rmTouchMove = null; + this.rmTouchEnd = null; + this.rmMouseStart = null; + this.rmMouseMove = null; + this.rmMouseUp = null; + + this.pointerDown = null; + this.pointerMove = null; + this.pointerUp = null; + + this.ele = null; + } + +} + +export class UIEventManager { + private events: Function[] = []; + + constructor(public zoneWrapped: boolean = true) {} + + listenRef(ref: ElementRef, eventName: string, callback: any, option?: any): Function { + return this.listen(ref.nativeElement, eventName, callback, option); + } + + pointerEventsRef(ref: ElementRef, pointerStart: any, pointerMove: any, pointerEnd: any, option?: any): Function { + return this.pointerEvents(ref.nativeElement, pointerStart, pointerMove, pointerEnd, option); + } + + pointerEvents(element: any, pointerDown: any, pointerMove: any, pointerUp: any, option: any = false): Function { + let submanager = new PointerEvents( + element, + pointerDown, + pointerMove, + pointerUp, + this.zoneWrapped, + option); + + let removeFunc = () => submanager.destroy(); + this.events.push(removeFunc); + return removeFunc; + } + + listen(element: any, eventName: string, callback: any, option: any = false): Function { + let removeFunc = listenEvent(element, eventName, this.zoneWrapped, option, callback); + this.events.push(removeFunc); + return removeFunc; + } + + unlistenAll() { + for (let event of this.events) { + event(); + } + this.events.length = 0; + } +} + +function listenEvent(ele: any, eventName: string, zoneWrapped: boolean, option: any, callback: any): Function { + let rawEvent = ('__zone_symbol__addEventListener' in ele && !zoneWrapped); + if (rawEvent) { + ele.__zone_symbol__addEventListener(eventName, callback, option); + return () => ele.__zone_symbol__removeEventListener(eventName, callback); + } else { + ele.addEventListener(eventName, callback, option); + return () => ele.removeEventListener(eventName, callback); + } +}