From dd30859137948e682ff753d033661e215525d585 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Sat, 28 May 2016 12:16:20 +0200 Subject: [PATCH] feat(item): two-way sliding of items closes #5073 --- src/components/item/item-sliding-gesture.ts | 252 ++++----------- src/components/item/item-sliding.scss | 60 +++- src/components/item/item-sliding.ts | 322 +++++++++++++++++++- src/components/item/item.ts | 7 + src/components/item/test/sliding/index.ts | 3 + src/components/item/test/sliding/main.html | 21 +- src/components/list/list.ts | 32 +- src/config/directives.ts | 3 +- 8 files changed, 463 insertions(+), 237 deletions(-) diff --git a/src/components/item/item-sliding-gesture.ts b/src/components/item/item-sliding-gesture.ts index cf0b8e2e649..bd1675d961d 100644 --- a/src/components/item/item-sliding-gesture.ts +++ b/src/components/item/item-sliding-gesture.ts @@ -1,257 +1,131 @@ -import {DIRECTION_RIGHT} from '../../gestures/hammer'; import {DragGesture} from '../../gestures/drag-gesture'; +import {ItemSliding} from './item-sliding'; import {List} from '../list/list'; -import {CSS, nativeRaf, closest} from '../../util/dom'; +import {closest} from '../../util/dom'; +const DRAG_THRESHOLD = 20; +const MAX_ATTACK_ANGLE = 20; export class ItemSlidingGesture extends DragGesture { - canDrag: boolean = true; - data = {}; - openItems: number = 0; onTap: any; - onMouseOut: any; + canDrag: boolean = true; preventDrag: boolean = false; dragEnded: boolean = true; + selectedContainer: ItemSliding = null; constructor(public list: List, public listEle: HTMLElement) { super(listEle, { direction: 'x', threshold: DRAG_THRESHOLD }); - + this.onTap = (event: any) => this.onTapCallback(event); this.listen(); + } - this.onTap = (ev: UIEvent) => { - if (!isFromOptionButtons(ev.target)) { - let didClose = this.closeOpened(); - if (didClose) { - console.debug('tap close sliding item'); - preventDefault(ev); - } - } - }; - - this.onMouseOut = (ev: any) => { - if (ev.target.tagName === 'ION-ITEM-SLIDING') { - console.debug('tap close sliding item'); - this.onDragEnd(ev); - } - }; + onTapCallback(ev: any) { + if (isFromOptionButtons(ev.target)) { + return; + } + let didClose = this.closeOpened(); + if (didClose) { + console.debug('tap close sliding item, preventDefault'); + ev.preventDefault(); + } } onDragStart(ev: any): boolean { - let itemContainerEle = getItemContainer(ev.target); - if (!itemContainerEle) { - console.debug('onDragStart, no itemContainerEle'); + let angle = Math.abs(ev.angle); + if (angle > MAX_ATTACK_ANGLE && Math.abs(angle - 180) > MAX_ATTACK_ANGLE) { return false; } - this.closeOpened(itemContainerEle); + if (this.selectedContainer) { + console.debug('onDragStart, another container is already selected'); + return false; + } - let openAmout = this.getOpenAmount(itemContainerEle); - let itemData = this.get(itemContainerEle); - this.preventDrag = (openAmout > 0); + let container = getContainer(ev); + if (!container) { + console.debug('onDragStart, no itemContainerEle'); + return false; + } + // Close all item sliding containers but the selected one + this.preventDrag = container.getOpenAmount() > 0; if (this.preventDrag) { this.closeOpened(); console.debug('onDragStart, preventDefault'); - preventDefault(ev); - return; + ev.preventDefault(); + return false; } - itemContainerEle.classList.add('active-slide'); - - this.set(itemContainerEle, 'offsetX', openAmout); - this.set(itemContainerEle, 'startX', ev.center[this.direction]); - + // Close all item sliding containers but the selected one + this.closeOpened(container); this.dragEnded = false; + this.selectedContainer = container; + container.startSliding(ev.center.x); return true; } onDrag(ev: any): boolean { - if (this.dragEnded || this.preventDrag || Math.abs(ev.deltaY) > 30) { + if (this.dragEnded || this.preventDrag) { console.debug('onDrag preventDrag, dragEnded:', this.dragEnded, 'preventDrag:', this.preventDrag, 'ev.deltaY:', Math.abs(ev.deltaY)); this.preventDrag = true; return; } - - let itemContainerEle = getItemContainer(ev.target); - if (!itemContainerEle || !isActive(itemContainerEle)) { - console.debug('onDrag, no itemContainerEle'); - return; - } - - let itemData = this.get(itemContainerEle); - - if (!itemData.optsWidth) { - itemData.optsWidth = getOptionsWidth(itemContainerEle); - if (!itemData.optsWidth) { - console.debug('onDrag, no optsWidth'); - return; - } - } - - let x = ev.center[this.direction]; - let delta = x - itemData.startX; - - let newX = Math.max(0, itemData.offsetX - delta); - - if (newX > itemData.optsWidth) { - // Calculate the new X position, capped at the top of the buttons - newX = -Math.min(-itemData.optsWidth, -itemData.optsWidth + (((delta + itemData.optsWidth) * 0.4))); - } - - if (newX > 5 && ev.srcEvent.type.indexOf('mouse') > -1 && !itemData.hasMouseOut) { - itemContainerEle.addEventListener('mouseout', this.onMouseOut); - itemData.hasMouseOut = true; + if (this.selectedContainer) { + this.selectedContainer.moveSliding(ev.center.x); + ev.preventDefault(); } - - nativeRaf(() => { - if (!this.dragEnded && !this.preventDrag) { - isItemActive(itemContainerEle, true); - this.open(itemContainerEle, newX, false); - } - }); } onDragEnd(ev: any) { this.preventDrag = false; this.dragEnded = true; - let itemContainerEle = getItemContainer(ev.target); - if (!itemContainerEle || !isActive(itemContainerEle)) { - console.debug('onDragEnd, no itemContainerEle'); - return; - } - - // If we are currently dragging, we want to snap back into place - // The final resting point X will be the width of the exposed buttons - let itemData = this.get(itemContainerEle); - - var restingPoint = itemData.optsWidth; - - // Check if the drag didn't clear the buttons mid-point - // and we aren't moving fast enough to swipe open + if (this.selectedContainer) { + let openAmount = this.selectedContainer.endSliding(ev.velocityX); + this.selectedContainer = null; - if (this.getOpenAmount(itemContainerEle) < (restingPoint / 2)) { - - // If we are going left but too slow, or going right, go back to resting - if (ev.direction & DIRECTION_RIGHT || Math.abs(ev.velocityX) < 0.3) { - restingPoint = 0; + // TODO: I am not sure listening for a tap event is the best idea + // we should try mousedown/touchstart + if (openAmount === 0) { + this.off('tap', this.onTap); + } else { + this.on('tap', this.onTap); } } - - itemContainerEle.removeEventListener('mouseout', this.onMouseOut); - itemData.hasMouseOut = false; - - nativeRaf(() => { - this.open(itemContainerEle, restingPoint, true); - }); } - closeOpened(doNotCloseEle?: HTMLElement) { + closeOpened(doNotClose?: ItemSliding): boolean { let didClose = false; - if (this.openItems) { - let openItemElements = this.listEle.querySelectorAll('.active-slide'); - for (let i = 0; i < openItemElements.length; i++) { - if (openItemElements[i] !== doNotCloseEle) { - this.open(openItemElements[i], 0, true); - didClose = true; - } + let openItemElements = this.listEle.querySelectorAll('.active-slide'); + for (var i = 0; i < openItemElements.length; i++) { + var component = openItemElements[i]['$ionComponent']; + if (component && component !== doNotClose) { + component.close(); + didClose = true; } } return didClose; } - open(itemContainerEle: any, openAmount: number, isFinal: boolean) { - let slidingEle = itemContainerEle.querySelector('ion-item,[ion-item]'); - if (!slidingEle) { - console.debug('open, no slidingEle, openAmount:', openAmount); - return; - } - - this.set(itemContainerEle, 'openAmount', openAmount); - - clearTimeout(this.get(itemContainerEle).timerId); - - if (openAmount) { - this.openItems++; - - } else { - let timerId = setTimeout(() => { - if (slidingEle.style[CSS.transform] === '') { - isItemActive(itemContainerEle, false); - this.openItems--; - } - }, 400); - this.set(itemContainerEle, 'timerId', timerId); - } - - slidingEle.style[CSS.transition] = (isFinal ? '' : 'none'); - slidingEle.style[CSS.transform] = (openAmount ? 'translate3d(' + -openAmount + 'px,0,0)' : ''); - - if (isFinal) { - if (openAmount) { - isItemActive(itemContainerEle, true); - this.on('tap', this.onTap); - - } else { - this.off('tap', this.onTap); - } - } - } - - getOpenAmount(itemContainerEle: any) { - return this.get(itemContainerEle).openAmount || 0; - } - - get(itemContainerEle: any) { - return this.data[itemContainerEle && itemContainerEle.$ionSlide] || {}; - } - - set(itemContainerEle: any, key: any, value: any) { - if (!this.data[itemContainerEle.$ionSlide]) { - this.data[itemContainerEle.$ionSlide] = {}; - } - this.data[itemContainerEle.$ionSlide][key] = value; - } - unlisten() { super.unlisten(); this.listEle = null; } } -function isItemActive(ele: any, isActive: boolean) { - ele.classList[isActive ? 'add' : 'remove']('active-slide'); - ele.classList[isActive ? 'add' : 'remove']('active-options'); -} - -function preventDefault(ev: any) { - console.debug('sliding item preventDefault', ev.type); - ev.preventDefault(); -} - -function getItemContainer(ele: any) { - return closest(ele, 'ion-item-sliding', true); -} - -function isFromOptionButtons(ele: any) { - return !!closest(ele, 'ion-item-options', true); -} - -function getOptionsWidth(itemContainerEle: any) { - let optsEle = itemContainerEle.querySelector('ion-item-options'); - if (optsEle) { - return optsEle.offsetWidth; +function getContainer(ev: any): ItemSliding { + let ele = closest(ev.target, 'ion-item-sliding', true); + if (ele) { + return ele['$ionComponent']; } + return null; } -function isActive(itemContainerEle: any) { - return itemContainerEle.classList.contains('active-slide'); +function isFromOptionButtons(ele: HTMLElement): boolean { + return !!closest(ele, 'ion-item-options', true); } - - -const DRAG_THRESHOLD = 20; diff --git a/src/components/item/item-sliding.scss b/src/components/item/item-sliding.scss index 93d0b41d5a1..15c328bf7aa 100644 --- a/src/components/item/item-sliding.scss +++ b/src/components/item/item-sliding.scss @@ -4,7 +4,6 @@ // -------------------------------------------------- // The hidden right-side buttons that can be exposed under a list item with dragging. - ion-item-sliding { position: relative; display: block; @@ -22,23 +21,35 @@ ion-item-options { z-index: $z-index-item-options; display: none; + justify-content: flex-end; + height: 100%; + font-size: 14px; + visibility: hidden; } +ion-item-options[side=left] { + right: auto; + left: 0; + + justify-content: flex-start; +} + ion-item-options .button { margin: 0; + padding: 0 .7em; height: 100%; border-radius: 0; box-shadow: none; + + box-sizing: content-box; } ion-item-options:not([icon-left]) .button-icon-left { - font-size: 14px; - .button-inner { flex-direction: column; } @@ -60,15 +71,52 @@ ion-item-sliding.active-slide { opacity: 1; transition: all 300ms cubic-bezier(.36, .66, .04, 1); - pointer-events: none; - + pointer-events: all; } ion-item-options { display: flex; } - &.active-options ion-item-options { + &.active-options-left ion-item-options[side=left] { visibility: visible; } + + &.active-options-right ion-item-options:not([side=left]) { + visibility: visible; + } +} + +// Item Swipeable Animation +// -------------------------------------------------- + +button[swipeable] { + flex-shrink: 0; + + transition-duration: .6s; + transition-property: none; + transition-timing-function: cubic-bezier(.65, .05, .36, 1); +} + +ion-item-sliding.active-swipe-right, +ion-item-sliding.active-swipe-left { + ion-item-options { + width: 100%; + } +} + +ion-item-sliding.active-swipe-right button[swipeable] { + order: 1; + + padding-left: 90%; + + transition-property: padding-left; +} + +ion-item-sliding.active-swipe-left button[swipeable] { + order: 1; + + padding-right: 90%; + + transition-property: padding-right; } diff --git a/src/components/item/item-sliding.ts b/src/components/item/item-sliding.ts index 647f2b75285..fa746a12f48 100644 --- a/src/components/item/item-sliding.ts +++ b/src/components/item/item-sliding.ts @@ -1,6 +1,61 @@ -import {Component, ElementRef, Optional, ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core'; +import {ChangeDetectionStrategy, Component, ContentChildren, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, Input, Optional, Output, QueryList, Renderer, ViewEncapsulation} from '@angular/core'; import {List} from '../list/list'; +import {Ion} from '../ion'; +import {Item} from './item'; +import {isPresent} from '../../util/util'; +import {CSS} from '../../util/dom'; + +const SWIPE_FACTOR = 1.1; +const ELASTIC_FACTOR = 0.55; + +export const enum SideFlags { + None = 0, + Left = 1 << 0, + Right = 1 << 1, + Both = Left | Right +} + +/** + * @private + */ +@Directive({ + selector: 'ion-item-options', +}) +export class ItemOptions extends Ion { + @Input() side: string; + @Output() ionSwipe: EventEmitter = new EventEmitter(); + + constructor(elementRef: ElementRef, private _renderer: Renderer) { + super(elementRef); + } + + /** + * @private + */ + setCssStyle(property: string, value: string) { + this._renderer.setElementStyle(this.elementRef.nativeElement, property, value); + } + + /** + * @private + */ + getSides(): SideFlags { + if (isPresent(this.side) && this.side === 'left') { + return SideFlags.Left; + } else { + return SideFlags.Right; + } + } + +} + +const enum SlidingState { + Disabled = 0, + Enabled = 1, + Right = 2, + Left = 3 +} /** @@ -11,14 +66,42 @@ import {List} from '../list/list'; * an [Item](../Item) component as a child and a [List](../../list/List) component as * a parent. All buttons to reveal can be placed in the `` element. * - * ### Button Layout - * If an icon is placed with text in the option button, by default it will - * display the icon on top of the text. This can be changed to display the icon - * to the left of the text by setting `icon-left` as an attribute on the - * `` element. + * ### Swipe Direction + * By default, the buttons are revealed when the sliding item is swiped from right to left, + * so the buttons are placed in the right side. But it's also possible to reveal them + * in the right side (sliding from left to right) by setting the `side` attribute + * on the `ion-item-options` element. Up to 2 `ion-item-options` can used at the same time + * in order to reveal two different sets of buttons depending the swipping direction. * * ```html - * + * + * + * + + * + * + * + * ``` + * + * ### Listening for events (ionDrag) and (ionSwipe) + * It's possible to know the current relative position of the sliding item by subscribing + * to the (ionDrag)` event. + * + * ```html + * + * + * + + * * + * + * + * ``` * * @usage * ```html * - * + * * * Item * @@ -38,6 +135,10 @@ import {List} from '../list/list'; * * * + + * + * + * * * * ``` @@ -53,13 +154,208 @@ import {List} from '../list/list'; '' + '', changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, + encapsulation: ViewEncapsulation.None }) export class ItemSliding { + private _openAmount: number = 0; + private _startX: number = 0; + private _offsetX: number = 0; + private _optsWidthRightSide: number = 0; + private _optsWidthLeftSide: number = 0; + private _sides: SideFlags; + private _timer: number = null; + private _leftOptions: ItemOptions; + private _rightOptions: ItemOptions; + private _optsDirty: boolean = true; + private _state: SlidingState = SlidingState.Disabled; + slidingPercent: number = 0; - constructor(@Optional() private _list: List, elementRef: ElementRef) { + @ContentChild(Item) private item: Item; + + + /** + * @output {event} Expression to evaluate when the sliding position changes. + * It reports the relative position. + * + * ```ts + * ondrag(percent) { + * if (percent > 0) { + * // positive + * console.log('right side'); + * } else { + * // negative + * console.log('left side'); + * } + * if (Math.abs(percent) > 1) { + * console.log('overscroll'); + * } + * } + * ``` + * + */ + @Output() ionDrag: EventEmitter = new EventEmitter(); + + constructor(@Optional() private _list: List, private _renderer: Renderer, private _elementRef: ElementRef) { _list.enableSlidingItems(true); - elementRef.nativeElement.$ionSlide = ++slideIds; + _elementRef.nativeElement.$ionComponent = this; + } + + + /** + * @private + */ + @ContentChildren(ItemOptions) + set _itemOptions(itemOptions: QueryList) { + let sides = 0; + for (var item of itemOptions.toArray()) { + var side = item.getSides(); + if (side === SideFlags.Left) { + this._leftOptions = item; + } else { + this._rightOptions = item; + } + sides |= item.getSides(); + } + this._optsDirty = true; + this._sides = sides; + } + + /** + * @private + */ + startSliding(startX: number) { + this._setState(SlidingState.Enabled); + this._offsetX = this._openAmount; + this._optsDirty = true; + this._startX = startX; + this.item.setCssStyle(CSS.transition, 'none'); + } + + /** + * @private + */ + moveSliding(x: number): number { + this.calculateOptsWidth(); + + let delta = x - this._startX; + let openAmount = this._offsetX - delta; + switch (this._sides) { + case SideFlags.Right: openAmount = Math.max(0, openAmount); break; + case SideFlags.Left: openAmount = Math.min(0, openAmount); break; + case SideFlags.Both: break; + default: openAmount = 0; break; + } + + let optsWidth = (openAmount > 0) + ? this._optsWidthRightSide + : -this._optsWidthLeftSide; + + if (Math.abs(openAmount) > Math.abs(optsWidth)) { + openAmount = optsWidth - (optsWidth + delta) * ELASTIC_FACTOR; + } + + this._setOpenAmount(openAmount, false); + return openAmount; + } + + /** + * @private + */ + endSliding(velocity: number): number { + let restingPoint = (this._openAmount > 0) + ? this._optsWidthRightSide + : -this._optsWidthLeftSide; + + // Check if the drag didn't clear the buttons mid-point + // and we aren't moving fast enough to swipe open + let isOnResetZone = Math.abs(this._openAmount) < Math.abs(restingPoint / 2); + let isMovingSlow = Math.abs(velocity) < 0.3; + let isDirection = (this._openAmount > 0) === (velocity > 0); + if (isOnResetZone && (isMovingSlow || isDirection)) { + restingPoint = 0; + } + + this.fireSwipeEvent(); + this._setOpenAmount(restingPoint, true); + return restingPoint; + } + + fireSwipeEvent() { + if (this.slidingPercent > SWIPE_FACTOR) { + this._rightOptions.ionSwipe.emit(this.slidingPercent); + } else if (this.slidingPercent < -SWIPE_FACTOR) { + this._leftOptions.ionSwipe.emit(this.slidingPercent); + } + } + + calculateOptsWidth() { + if (this._optsDirty) { + if (this._rightOptions) { + this._optsWidthRightSide = this._rightOptions.width(); + } + if (this._leftOptions) { + this._optsWidthLeftSide = this._leftOptions.width(); + } + this._optsDirty = false; + } + } + + /** + * @private + */ + private _setOpenAmount(openAmount: number, isFinal: boolean) { + clearTimeout(this._timer); + this._timer = null; + this._openAmount = openAmount; + this.slidingPercent = 0; + + let didEnd = openAmount === 0; + if (didEnd) { + // TODO: refactor. there must exist a better way + // if sliding ended, we wait 400ms until animation finishes + this._timer = setTimeout(() => { + this._setState(SlidingState.Disabled); + this._timer = null; + }, 400); + + } else if (openAmount > 0) { + this._setState(SlidingState.Right); + this.slidingPercent = openAmount / this._optsWidthRightSide; + } else if (openAmount < 0) { + this._setState(SlidingState.Left); + this.slidingPercent = openAmount / this._optsWidthLeftSide; + } + if (!isFinal) { + this._setClass('active-swipe-right', this.slidingPercent > SWIPE_FACTOR); + this._setClass('active-swipe-left', this.slidingPercent < -SWIPE_FACTOR); + } else { + this.item.setCssStyle(CSS.transition, ''); + } + + this.ionDrag.emit(this.slidingPercent); + this.item.setCssStyle(CSS.transform, (didEnd ? '' : 'translate3d(' + -openAmount + 'px,0,0)')); + } + + private _setState(state: SlidingState) { + if (state !== this._state) { + this._state = state; + this._setClass('active-slide', state !== SlidingState.Disabled); + this._setClass('active-options-right', state === SlidingState.Right); + this._setClass('active-options-left', state === SlidingState.Left); + } + } + + /** + * @private + */ + private _setClass(className: string, add: boolean) { + this._renderer.setElementClass(this._elementRef.nativeElement, className, add); + } + /** + * @private + */ + getOpenAmount(): number { + return this._openAmount; } /** @@ -97,9 +393,7 @@ export class ItemSliding { * ``` */ close() { - this._list.closeSlidingItems(); + this._setOpenAmount(0, true); } } - -let slideIds = 0; diff --git a/src/components/item/item.ts b/src/components/item/item.ts index 53c8643931c..254d66abbc5 100644 --- a/src/components/item/item.ts +++ b/src/components/item/item.ts @@ -108,6 +108,13 @@ export class Item { this._renderer.setElementClass(this._elementRef.nativeElement, cssClass, shouldAdd); } + /** + * @private + */ + setCssStyle(property: string, value: string) { + this._renderer.setElementStyle(this._elementRef.nativeElement, property, value); + } + /** * @private */ diff --git a/src/components/item/test/sliding/index.ts b/src/components/item/test/sliding/index.ts index d9571ea7dbe..c3b87479470 100644 --- a/src/components/item/test/sliding/index.ts +++ b/src/components/item/test/sliding/index.ts @@ -21,6 +21,9 @@ class E2EPage { this.list.closeSlidingItems(); } + slide(progress) { + console.log(progress); + } didClick(item) { console.log('Clicked, ion-item'); diff --git a/src/components/item/test/sliding/main.html b/src/components/item/test/sliding/main.html index 15495fc7c3a..732aaf28b1c 100644 --- a/src/components/item/test/sliding/main.html +++ b/src/components/item/test/sliding/main.html @@ -11,12 +11,12 @@ - + @@ -24,7 +24,7 @@

Max Lynch

-

Adam Bradley

+

RIGHT side sliding only

I think I figured out how to get more Mountain Dew

@@ -34,7 +34,7 @@

Adam Bradley

Archive - @@ -43,14 +43,17 @@

Adam Bradley

-

Ben Sperry

+

RIGHT/LEFT sliding

I like paper

- - + + + + +
@@ -60,7 +63,7 @@

Ben Sperry

One Line w/ Icon, div only text
- @@ -74,7 +77,7 @@

Ben Sperry

One Line w/ Avatar, div only text -